From 12ef5d7eee9c1a9564e96d8ef8ea8b2faf254346 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 20:07:07 -0600 Subject: [PATCH 01/35] docs: add CLAUDE.md and modernization design spec Design approved in brainstorming session: uv packaging, Selenium 4 migration, session persistence rebuilt on JSON + session-ID reattach (replacing pickle/dill), configurable pacing, pytest unit+integration tiers, Sphinx docs. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 34 ++++ .../2026-07-07-wabot-modernization-design.md | 156 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/superpowers/specs/2026-07-07-wabot-modernization-design.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c901b99 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,34 @@ +# 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: + +1. **Page-object automation with state carried between pages** — consumers model each website page as a `Page` subclass and drive them through a single `BrowserProxy`. +2. **Browser sessions that survive the Python process** — a remote webdriver reference is persisted to disk so a *different* Python process can reattach to the same still-open browser. + +There is currently no test suite, linter, or CI. Packaging is a legacy `setup.py` (`pip install -e .` to develop). The code is pinned to Selenium 3-era APIs (`desired_capabilities`, `browser_profile`, `switch_to_alert`); it does not run against Selenium 4+ without migration. + +## Architecture + +Consumer code talks to one object, `BrowserProxy` (`wabot/api.py`), which composes everything else: + +- **`BrowserProxy`** holds the webdriver plus a *current page* object and acts as a facade. `__getattr__` delegates unknown attributes to the current page, so `bot.login()` invokes `bot.page.login()`; `bot[key]` resolves page elements. `set_page(PageClass)` swaps the current page and calls its `verify()` gate. `perform(name, ...)` invokes a page method with exception trapping — any failure flips `self.good = False`, after which all further actions are refused (`REFUSE_AFTER_EXCEPTION`), forcing an explicit `reset_good_status()`. +- **`Page`** (`wabot/page.py`) is the base class consumers subclass. Pages declare a class-level `elements = {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** (`wabot/fields.py`): `TextField`, `SelectField`, `CheckField` wrap elements with `get_value`/`set_value`; unknown attribute access falls through to the underlying selenium element. `NullField` is a falsy null-object returned for missing elements so callers can `if el:` instead of catching exceptions. +- **`CreateBrowser`** (`wabot/create_browser.py`) is the driver factory. Local drivers (Chrome/Firefox) are plain. Remote drivers (Selenium server expected at `localhost:4444`) are serialized with `dill` to `appdirs.user_data_dir('wabot')/saved_browser_instances.pickle`, keyed by session name with a timestamp (refs older than 3 days are purged). On the next request for the same session name, the pickled driver is loaded and validated by touching `driver.current_url` — that is the reattach mechanism. + +**Human-like behavior is intentional, not accidental slowness**: clicks land at Gaussian-random coordinates within the element instead of Selenium's default (0,0), and field setters `sleep(random.uniform(...))` between actions. Chrome full-page screenshots are stitched from viewport tiles with PIL to work around chromedriver capturing only the visible area. Don't "optimize" these away. + +## Gotchas + +- `LOGGER.trace(...)` is called throughout, but stdlib `logging` has no `trace` method — the consuming application is expected to register a custom TRACE level; without it those calls raise. +- `appdirs` and `Pillow` are imported but missing from `install_requires` in `setup.py` (only `dill` and `selenium` are declared). +- `wabot/__init__.py` star-exports `api`, `page`, and `fields`, so anything public in those modules is public API. +- Remote driver types require an external Selenium server already running on port 4444; nothing in this repo starts one. + +## 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). diff --git a/docs/superpowers/specs/2026-07-07-wabot-modernization-design.md b/docs/superpowers/specs/2026-07-07-wabot-modernization-design.md new file mode 100644 index 0000000..c02af96 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-wabot-modernization-design.md @@ -0,0 +1,156 @@ +# WABot Modernization — Design + +**Date:** 2026-07-07 +**Status:** Approved (brainstorming session with Mathew) +**Approach:** Rebuild the core on the existing design (approach B) — port proven logic into cleanly separated modules; redesign the session-persistence layer; free API redesign was explicitly approved (no downstream consumers to protect). + +## Context & goals + +WABot is a Selenium-wrapping browser-automation library (page-object pattern, stateful multi-page flows, anti-bot stealth behavior). It is stuck on Selenium 3-era APIs because its flagship feature — saving a browser so a *different* Python process can pick it up — was implemented by pickling live webdriver objects with `dill`, which broke on Selenium 4. + +Goals, as requested: + +1. Modern packaging with **uv** (`pyproject.toml`, lockfile). +2. **Sphinx + sphinx-rtd-theme** documentation. +3. **pytest** suite (unit + real-browser integration). +4. Migrate to **latest Selenium 4**, keeping core functionality — especially cross-process browser pickup, rebuilt on a sound mechanism. +5. General code quality: fix latent bugs, drop dead code, keep the behavioral subtleties that make the library effective. + +### Decisions from the brainstorming Q&A + +| Question | Decision | +|---|---| +| API compatibility | Free to redesign; keep the concepts, not the exact signatures | +| Browser host for persistence | **Both**: external Selenium server URL *and* wabot-spawned detached driver service | +| Test depth | Unit (mocked) + integration (real headless browsers, local HTML fixtures, cross-process reattach e2e) | +| Stealth pacing | Configurable policy; human-like behavior stays the **default** | + +### Key insight (why persistence broke, and the fix) + +A `Remote` webdriver is a thin HTTP client around `(command_executor_url, session_id)`. Pickling the whole object was always fragile and is unworkable on Selenium 4. The robust mechanism is to persist just the URL + session ID as JSON and **reattach** by constructing a `Remote` subclass whose `start_session` adopts the saved session ID instead of creating a new session. This works against any WebDriver-protocol server: a Selenium Grid *or* a bare chromedriver/geckodriver. `dill` is removed entirely. The browser must be hosted by a process that outlives Python — same constraint as the old design, where only `remote_*` drivers were persistable. + +## Tooling & layout + +- `pyproject.toml` managed by uv, `uv_build` backend, `requires-python = ">=3.10"`. +- Runtime deps: `selenium>=4` (latest), `platformdirs` (replaces dead `appdirs`), `Pillow`. **Removed:** `dill`, `appdirs`. +- Dev group: `pytest`, `pytest-cov`, `ruff` (lint + format), `sphinx`, `sphinx-rtd-theme`. +- Commands: `uv sync`, `uv run pytest`, `uv run pytest -m integration`, `uv run ruff check`, `uv run sphinx-build docs docs/_build/html`. + +``` +wabot/ +├── pyproject.toml +├── uv.lock +├── src/wabot/ +│ ├── __init__.py # explicit public API (no star exports); wabot.browser() entry point +│ ├── browser.py # Browser (was BrowserProxy) — facade over driver + current page +│ ├── page.py # Page base class + element resolution +│ ├── fields.py # TextField / SelectField / CheckField / NullField +│ ├── pacing.py # HumanPacing / NoPacing policies +│ ├── sessions.py # SessionStore — JSON persistence + stale eviction +│ ├── hosts.py # ExternalServer / ManagedService driver hosts +│ └── screenshot.py # Chromium full-page stitching (PIL) +├── tests/ +│ ├── unit/ +│ ├── integration/ +│ └── fixtures/ # static HTML pages (login form, multi-page flow) +└── docs/ # Sphinx +``` + +The legacy `setup.py` and top-level `wabot/` package are removed once the `src/` layout lands. + +## Session persistence layer + +Three cooperating pieces (this replaces all pickle logic in `create_browser.py`): + +**`SessionStore`** (`sessions.py`) +- JSON file at `platformdirs.user_data_dir('wabot')/sessions.json`. +- Schema: `{name: {executor_url, session_id, browser, created_at, service_pid?, service_port?}}`. +- Entries older than a configurable max age (default 3 days, matching current behavior) are evicted on load. +- A corrupt file is renamed aside (`sessions.json.bad`) and recreated — never a crash. + +**`DriverHost`** (`hosts.py`) — small interface: `ensure_running() -> executor_url`. +- `ExternalServer(url)`: a Selenium server/Grid the user runs; wabot just connects. Docs must note Grid 4 reaps idle sessions after 5 minutes unless `--session-timeout` is raised. +- `ManagedService(browser)`: wabot spawns `chromedriver`/`geckodriver` as a **detached** process (new process session, output redirected to a log file in the wabot data dir) so it outlives Python; records port + pid in the store. Driver binaries are found on `PATH` first, falling back to Selenium Manager's downloaded copies. On reattach the service's `/status` endpoint is health-checked before use. + +**Reattach** +- A private `Remote` subclass overrides `start_session` to adopt the saved `session_id` (no new session created). +- Liveness validation: touch `driver.current_url`. On failure: log a warning, prune the store entry, fall through to fresh creation. + +**Public flow** + +```python +import wabot + +bot = wabot.browser(session="scraper1", browser="firefox") # create + persist +# process exits; browser stays open +bot = wabot.browser(session="scraper1", browser="firefox") # second process reattaches +``` + +- `session=None` (default): ephemeral — the browser is not persisted and the store is untouched. With no `host`, this is a plain local Selenium driver that dies with the process; with a `host`, it connects to that server but the session is still not saved. +- `session="name"` + `host="http://..."` → persist via `ExternalServer`; `session="name"` without `host` → persist via `ManagedService`. +- Housekeeping: `wabot.sessions()` lists saved sessions; `wabot.destroy(name)` quits the browser, kills a managed service, removes the entry. + +## Core API + +**`Browser`** (`browser.py`, rename of `BrowserProxy`) +- Facade: holds driver + *current page*; `set_page(PageClass)` instantiates and runs the page's `verify()` gate; `__getattr__` delegates unknown attributes to the current page (`bot.login()` → `bot.page.login()`); `bot[key]` resolves elements on the current page. +- `good` flag properly initialized to `True` (today it is never set, so the refuse-after-exception guard is broken); `perform()` keeps trap-and-flip behavior; `reset_good_status()` retained. +- Dropped: unused `switch_after` decorator, NHSN-specific `RC` enum values, hardcoded `/home/mathew/firefox_prof` profile path, PhantomJS remnants, `firefox1/firefox2/chromium1/chromium2` naming (now just `firefox` / `chromium` — with `chrome` accepted as an alias, since chromedriver drives both — plus the optional `host`). +- User-agent becomes an optional parameter defaulting to the browser's own UA (the hardcoded 2006 MSIE 7 string is an anti-stealth beacon today). +- Logging: stdlib only — `LOGGER.trace` → `debug`, `LOGGER.warn` → `warning` (the `trace` calls crash under stdlib logging unless the host app registers a custom level). + +**`Page` + fields** — declarative design preserved +- Class-level `elements = {key: (type, accessors)}`; lookup walks the MRO so subclasses inherit/override element maps; `page[key]` returns typed wrappers; `NullField` stays the falsy null-object for missing elements. +- Consolidate the duplicated find/wait logic; replace bare `except:` with specific Selenium exception types. +- Selenium 4 port subtlety: `move_to_element_with_offset` now measures from the element **center** (was top-left in Selenium 3), so the Gaussian click math is recomputed relative to center — otherwise human-like clicks land outside small elements. + +**`pacing.py`** — stealth extracted into a policy +- `HumanPacing` (default): current behavior — random 3–11 s delays per field type, Gaussian click offsets. +- `NoPacing`: instant actions, exact clicks (used by tests and trusted-site automation). +- `Browser` holds the policy; fields and `Page.click` consult it instead of calling `time.sleep` directly. + +**Screenshots** (`screenshot.py`) +- Firefox: Selenium 4 native `get_full_page_screenshot_as_file`. +- Chromium: keep the PIL viewport-stitching workaround, isolated in this module. + +## Error handling + +Philosophy stays *log and refuse, don't crash the scraper*: +- Missing elements → `NullField`; failed actions → `False`; exceptions in `perform()` → logged with traceback, `good = False`. +- Dead reattach → warn, prune, create fresh (never crash on a stale session). +- Corrupt store → rename aside, rebuild. +- Dead managed service → detected by `/status` health check before driving. +- No bare `except:` — catch specific Selenium exceptions so real bugs surface in logs. + +## Testing + +Two pytest tiers split by the `integration` marker: + +**Unit** (default, fast, no browsers): element resolution through the MRO; field wrappers against a mocked driver; pacing policies; `SessionStore` round-trip, eviction, and corruption handling on `tmp_path`; reattach logic against a mocked executor. + +**Integration** (`-m integration`): real headless Firefox + Chromium driving static HTML fixtures (login form, multi-page flow) served locally — no external network. Flagship test: create a session via `ManagedService`, then a **second Python process** (via `subprocess`) reattaches by name and asserts browser state (URL, filled field value) survived — proving cross-process pickup end to end. `ExternalServer` mode is tested against a locally spawned chromedriver used as the "external" URL (no Java Grid required). Integration tests always run with `NoPacing`. + +## Documentation + +Sphinx in `docs/`, `sphinx-rtd-theme`, autodoc + napoleon (Google-style docstrings on the public API). Pages: +1. Quickstart — install, first scrape. +2. Persistent sessions guide — the flagship feature; managed services vs external servers; the Grid `--session-timeout` caveat. +3. Page-objects guide — modeling a site with `Page` + `elements`. +4. API reference (autodoc). + +Local build only (`uv run sphinx-build docs docs/_build/html`); no hosting setup for now. + +## Out of scope + +- CI pipeline (can be added later on Gitea). +- Read the Docs hosting. +- New automation features beyond parity (e.g., async API, non-Selenium backends). +- PhantomJS and the legacy `firefox1`-style driver taxonomy (deliberately removed). + +## Known latent bugs fixed by this work + +- `LOGGER.trace()` calls crash under stdlib logging. +- `_clean_old_pickles()` references undefined `final_name`. +- `BrowserProxy.get_driver()` calls `self._create_driver_chromium()` which doesn't exist on that class. +- `self.good` never initialized, breaking the refuse-after-exception guard. +- `appdirs` and `Pillow` used but undeclared in `setup.py`. From f3f6d23cc60ea1b003b4ad7754aba24820621f14 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 21:20:43 -0600 Subject: [PATCH 02/35] docs: implementation plan for the modernization (18 TDD tasks) APIs verified against installed selenium 4.45.0 / uv 0.11.26 / pytest 9.1.1 / sphinx 9.1.0, including a live end-to-end run of the session-reattach pattern against chromedriver 150. Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-07-wabot-modernization.md | 3306 +++++++++++++++++ 1 file changed, 3306 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-wabot-modernization.md diff --git a/docs/superpowers/plans/2026-07-07-wabot-modernization.md b/docs/superpowers/plans/2026-07-07-wabot-modernization.md new file mode 100644 index 0000000..323dd7e --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-wabot-modernization.md @@ -0,0 +1,3306 @@ +# WABot Modernization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild wabot on Selenium 4 + uv with pytest and Sphinx docs, replacing pickle/dill browser persistence with JSON session records + a session-ID reattach mechanism. + +**Architecture:** A `Browser` facade drives declarative `Page` objects with typed field wrappers; a `SessionStore` (JSON in platformdirs) plus two `DriverHost` implementations (`ExternalServer`, detached `ManagedService`) make browser sessions survive the Python process; a `ReattachingRemote` webdriver subclass adopts saved sessions. Stealth behavior (random delays, Gaussian clicks) is a swappable pacing policy. + +**Tech Stack:** Python ≥3.10, selenium ≥4.45 <5, platformdirs, Pillow, uv (`uv_build` backend), pytest (unit + `integration` marker), ruff, Sphinx + sphinx-rtd-theme. + +**Spec:** `docs/superpowers/specs/2026-07-07-wabot-modernization-design.md` (approved). Work on branch `feature/modernization`. + +--- + +## Verified API facts (do not re-derive; verified 2026-07-07 on this machine) + +These were verified against installed selenium 4.45.0 source and live runs with chromedriver 150 + chromium and geckodriver 0.37.0. Trust them over your training data: + +1. `webdriver.Remote(command_executor=url, options=opts)` — `options` is required in practice (raises `TypeError` if None); `desired_capabilities`/`browser_profile` were removed in 4.10.0. +2. **Reattach pattern (verified live end-to-end):** subclass `selenium.webdriver.remote.webdriver.WebDriver`, stash the session id under a private attr (base `__init__` resets `self.session_id = None` *before* calling `start_session`), and override `start_session(self, capabilities)` to set only `self.session_id` and `self.caps`. Nothing else needed for normal WebDriver commands. +3. `GET /status` works on standalone chromedriver and geckodriver: `{"value": {"ready": bool, "message": str}}`. **chromedriver reports `ready:true` even with active sessions; geckodriver (single-session) flips `ready:false` while busy** — so liveness checks must accept an HTTP 200 with valid JSON and must NOT require `ready == true`. +4. `driver.quit()` on a plain `Remote` deletes the session + browser but **never kills the standalone driver server** (that's what makes reuse viable). After a reattached driver quits, other handles to that session get `InvalidSessionIdException`. +5. `SeleniumManager().binary_paths(["--browser", "chrome"])` → `{"driver_path": ..., "browser_path": ...}` exists but is explicitly beta — guard with try/except; prefer `shutil.which` first. +6. `ActionChains.move_to_element_with_offset(el, x, y)` offsets are **from the element's in-view center** in all of Selenium 4 (was top-left in Selenium 3 JSON-Wire). +7. Firefox full-page screenshot helpers (`get_full_page_screenshot_as_file` etc.) exist **only on `webdriver.Firefox`, not on `Remote`**. Over Remote you must register the raw command: `driver.command_executor._commands["FULL_PAGE_SCREENSHOT"] = ("GET", "/session/$sessionId/moz/screenshot/full")` then `driver.execute("FULL_PAGE_SCREENSHOT")["value"]` (base64). No Chrome equivalent. +8. Headless: Chrome `options.add_argument("--headless=new")`; Firefox `options.add_argument("-headless")`. The old `options.headless` property is gone. +9. User-agent: Chrome `add_argument(f"--user-agent={ua}")`; Firefox `options.set_preference("general.useragent.override", ua)` (no FirefoxProfile needed). +10. `driver.switch_to.alert` is a property (legacy `switch_to_alert()` removed); `EC.alert_is_present()` unchanged. +11. `options.binary_location = "/usr/bin/chromium"` still correct for pointing chromedriver at Chromium. +12. uv: `[build-system] requires = ["uv_build>=0.11,<0.12"]`, `build-backend = "uv_build"`, src layout `src/wabot/__init__.py` is the default (no module-name config needed). `[dependency-groups] dev = [...]` (PEP 735) is installed by `uv sync` by default. +13. pytest: a `-m` on the CLI **overrides** `-m` in `addopts` (last-wins) — verified on pytest 9.1.1. So `addopts = '-m "not integration"'` + `uv run pytest -m integration` works. +14. Sphinx: `html_theme = "sphinx_rtd_theme"` only — do **not** add the theme to `extensions`. `sphinx-rtd-theme>=3` supports sphinx 6–9. Build: `uv run sphinx-build -M html docs docs/_build`. + +--- + +## File structure + +``` +wabot/ (repo root, branch feature/modernization) +├── pyproject.toml # NEW — uv project, replaces setup.py +├── uv.lock # NEW — generated by uv sync +├── README.md # rewritten in Task 18 +├── CLAUDE.md # updated in Task 18 +├── src/wabot/ +│ ├── __init__.py # public API: browser(), sessions(), destroy(), re-exports +│ ├── pacing.py # NoPacing / HumanPacing +│ ├── sessions.py # SessionRecord, SessionStore +│ ├── hosts.py # build_options, service_alive, ExternalServer, +│ │ # ManagedService, find_driver_binary, stop_service, +│ │ # ReattachingRemote, attach +│ ├── fields.py # PageObject, TextField, SelectField, CheckField, NullField +│ ├── page.py # Page (element maps, click, forms, alerts, waits) +│ ├── browser.py # Browser facade +│ └── screenshot.py # save_full_page (firefox raw cmd / chromium stitch) +├── tests/ +│ ├── unit/ # test_pacing.py, test_sessions.py, test_hosts.py, +│ │ # test_reattach.py, test_fields.py, test_page.py, +│ │ # test_browser.py, test_api.py +│ ├── integration/ # conftest.py, test_ephemeral.py, test_persistence.py, +│ │ # test_external_server.py +│ └── fixtures/ # login.html, welcome.html +└── docs/ # conf.py, index.rst, quickstart.rst, sessions.rst, + # page-objects.rst, api.rst +``` + +Deleted in Task 1: legacy `wabot/` package (old `api.py`, `create_browser.py`, `page.py`, `fields.py`) and `setup.py`. The old code stays reachable in git history; everything worth porting is reproduced verbatim in this plan. + +--- + +### Task 1: Scaffold — uv project, src layout, remove legacy package + +**Files:** +- Create: `pyproject.toml` +- Create: `src/wabot/__init__.py` +- Create: `tests/unit/__init__.py`, `tests/integration/__init__.py` (empty files) +- Delete: `setup.py`, `wabot/__init__.py`, `wabot/api.py`, `wabot/create_browser.py`, `wabot/page.py`, `wabot/fields.py` + +- [ ] **Step 1: Verify you are on the feature branch** + +Run: `git -C /home/mathew/dev/Zavage-Software/wabot branch --show-current` +Expected: `feature/modernization`. All subsequent commands run from the repo root `/home/mathew/dev/Zavage-Software/wabot`. + +- [ ] **Step 2: Delete the legacy package and setup.py** + +```bash +git rm -r wabot setup.py +``` + +- [ ] **Step 3: Write `pyproject.toml`** + +```toml +[project] +name = "wabot" +version = "0.2.0" +description = "Stateful Selenium browser automation with sessions that survive the Python process" +readme = "README.md" +authors = [{ name = "Mathew Guest", email = "t3h.zavage@gmail.com" }] +requires-python = ">=3.10" +dependencies = [ + "selenium>=4.45,<5", + "platformdirs>=4", + "pillow>=10", +] + +[dependency-groups] +dev = [ + "pytest>=8", + "pytest-cov>=5", + "ruff>=0.8", + "sphinx>=7", + "sphinx-rtd-theme>=3", +] + +[build-system] +requires = ["uv_build>=0.11,<0.12"] +build-backend = "uv_build" + +[tool.pytest.ini_options] +addopts = '-m "not integration"' +markers = [ + "integration: real-browser tests (run with: uv run pytest -m integration)", +] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM"] +``` + +Note: if `uv sync` reports it cannot resolve sphinx for Python 3.10, change `requires-python` to `">=3.11"` — the library has no 3.10-specific consumers; this is the only sanctioned deviation. + +- [ ] **Step 4: Create the package skeleton** + +`src/wabot/__init__.py`: + +```python +"""wabot — stateful Selenium browser automation with reattachable sessions.""" +``` + +Create empty `tests/unit/__init__.py` and `tests/integration/__init__.py`. + +- [ ] **Step 5: Sync and sanity-check** + +Run: `uv sync` +Expected: creates `.venv` and `uv.lock`; installs selenium 4.45.x, platformdirs, pillow + dev group. + +Run: `uv run pytest` +Expected: `no tests ran` (exit code 5 is fine at this stage). + +Run: `uv run python -c "import wabot; print(wabot.__doc__)"` +Expected: prints the docstring. + +- [ ] **Step 6: Commit** + +```bash +git add pyproject.toml uv.lock src tests +git commit -m "build: replace setup.py with uv project, src layout" +``` + +--- + +### Task 2: `pacing.py` — NoPacing / HumanPacing + +**Files:** +- Create: `src/wabot/pacing.py` +- Test: `tests/unit/test_pacing.py` + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/test_pacing.py`: + +```python +from wabot.pacing import HumanPacing, NoPacing + + +class TestNoPacing: + def test_delay_is_zero_for_every_action(self): + p = NoPacing() + assert p.delay("text") == 0.0 + assert p.delay("select") == 0.0 + assert p.delay("unknown-action") == 0.0 + + def test_click_offset_is_none(self): + assert NoPacing().click_offset(200, 50) is None + + +class TestHumanPacing: + def test_delays_fall_in_documented_ranges(self): + p = HumanPacing() + for _ in range(50): + assert 3.0 <= p.delay("text") <= 5.0 + assert 6.0 <= p.delay("select") <= 11.0 + assert 2.0 <= p.delay("checkbox") <= 3.0 + assert 3.0 <= p.delay("navigate") <= 6.0 + + def test_unknown_action_has_no_delay(self): + assert HumanPacing().delay("unknown-action") == 0.0 + + def test_click_offset_stays_inside_element(self): + p = HumanPacing() + for _ in range(200): + offset = p.click_offset(100, 40) + assert offset is not None + x, y = offset + # offsets are measured from the element CENTER (selenium 4) + assert -49 <= x <= 49 + assert -19 <= y <= 19 + + def test_tiny_elements_get_plain_click(self): + p = HumanPacing() + assert p.click_offset(4, 40) is None + assert p.click_offset(40, 4) is None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/test_pacing.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'wabot.pacing'` + +- [ ] **Step 3: Write the implementation** + +`src/wabot/pacing.py`: + +```python +"""Pacing policies: how human-like the bot's interactions are. + +``HumanPacing`` reproduces legacy wabot stealth behavior (random delays +between actions, Gaussian click offsets). ``NoPacing`` is instant and +exact — used by the test suite and for trusted-site automation. +""" + +from __future__ import annotations + +import random + + +class NoPacing: + """Instant actions, exact clicks.""" + + def delay(self, action: str) -> float: + """Seconds to sleep before performing ``action``.""" + return 0.0 + + def click_offset(self, width: int, height: int) -> tuple[int, int] | None: + """Offset from the element center to click at, or None for a plain click.""" + return None + + +class HumanPacing(NoPacing): + """Random delays and Gaussian click offsets (anti-bot stealth).""" + + DELAYS: dict[str, tuple[float, float]] = { + "text": (3.0, 5.0), + "select": (6.0, 11.0), + "checkbox": (2.0, 3.0), + "navigate": (3.0, 6.0), + } + MIN_DIMENSION = 5 # elements thinner than this get a plain centered click + + def delay(self, action: str) -> float: + lo, hi = self.DELAYS.get(action, (0.0, 0.0)) + return random.uniform(lo, hi) + + def click_offset(self, width: int, height: int) -> tuple[int, int] | None: + if width < self.MIN_DIMENSION or height < self.MIN_DIMENSION: + return None + return (self._axis_offset(width), self._axis_offset(height)) + + @staticmethod + def _axis_offset(size: int) -> int: + # Selenium 4 measures offsets from the element's in-view center, so a + # human-looking click is a Gaussian around 0 clamped inside the element. + max_offset = size // 2 - 1 + offset = int(random.gauss(0.0, size / 7.0)) + return max(-max_offset, min(max_offset, offset)) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/test_pacing.py -v` +Expected: 6 passed + +- [ ] **Step 5: Commit** + +```bash +git add src/wabot/pacing.py tests/unit/test_pacing.py +git commit -m "feat: pacing policies (HumanPacing default behavior, NoPacing for tests)" +``` + +--- +### Task 3: `sessions.py` — SessionRecord + SessionStore + +**Files:** +- Create: `src/wabot/sessions.py` +- Test: `tests/unit/test_sessions.py` + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/test_sessions.py`: + +```python +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()) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/test_sessions.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'wabot.sessions'` + +- [ ] **Step 3: Write the implementation** + +`src/wabot/sessions.py`: + +```python +"""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)) +``` + +Note the corrupt-file test writes `sessions.json` + `.bad` → `sessions.json.bad`, which is why `_write` uses `with_name(self.path.name + ".bad")` (not `with_suffix`, which would replace `.json`). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/test_sessions.py -v` +Expected: 8 passed + +- [ ] **Step 5: Commit** + +```bash +git add src/wabot/sessions.py tests/unit/test_sessions.py +git commit -m "feat: JSON SessionStore with stale eviction (replaces pickle persistence)" +``` + +--- + +### Task 4: `hosts.py` (part 1) — browser normalization, options builder, ExternalServer, service_alive + +**Files:** +- Create: `src/wabot/hosts.py` +- Test: `tests/unit/test_hosts.py` + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/test_hosts.py`: + +```python +import http.server +import json +import threading + +import pytest + +from wabot.hosts import ExternalServer, build_options, normalize_browser, service_alive + + +class TestNormalizeBrowser: + def test_chrome_is_an_alias_for_chromium(self): + assert normalize_browser("chrome") == "chromium" + + def test_firefox_and_chromium_pass_through(self): + assert normalize_browser("firefox") == "firefox" + assert normalize_browser("chromium") == "chromium" + + def test_unknown_browser_raises(self): + with pytest.raises(ValueError, match="unsupported browser"): + normalize_browser("safari") + + +class TestBuildOptions: + def test_chromium_headless_and_user_agent(self): + opts = build_options("chromium", headless=True, user_agent="MyBot/1.0") + assert "--headless=new" in opts.arguments + assert "--user-agent=MyBot/1.0" in opts.arguments + + def test_chromium_defaults_have_no_headless_or_ua(self): + opts = build_options("chromium") + assert not any(a.startswith(("--headless", "--user-agent")) for a in opts.arguments) + + def test_firefox_headless_and_user_agent(self): + opts = build_options("firefox", headless=True, user_agent="MyBot/1.0") + assert "-headless" in opts.arguments + assert opts.preferences.get("general.useragent.override") == "MyBot/1.0" + + def test_options_type_matches_browser(self): + assert type(build_options("firefox")).__module__.startswith("selenium.webdriver.firefox") + assert type(build_options("chrome")).__module__.startswith("selenium.webdriver.chrome") + + +class _StatusHandler(http.server.BaseHTTPRequestHandler): + ready = True + + def do_GET(self): + if self.path == "/status": + body = json.dumps({"value": {"ready": self.ready, "message": ""}}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, *args): # keep test output clean + pass + + +@pytest.fixture +def status_server(): + server = http.server.HTTPServer(("127.0.0.1", 0), _StatusHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{server.server_address[1]}" + server.shutdown() + + +class TestServiceAlive: + def test_true_when_status_answers(self, status_server): + assert service_alive(status_server) is True + + def test_true_even_when_not_ready(self, status_server): + # geckodriver reports ready=false while its single session is in use; + # aliveness must not require ready==true + _StatusHandler.ready = False + try: + assert service_alive(status_server) is True + finally: + _StatusHandler.ready = True + + def test_false_when_nothing_listens(self): + assert service_alive("http://127.0.0.1:1") is False + + +class TestExternalServer: + def test_returns_url_when_alive(self, status_server): + assert ExternalServer(status_server + "/").ensure_running() == status_server + + def test_raises_when_dead(self): + with pytest.raises(ConnectionError, match="no WebDriver server"): + ExternalServer("http://127.0.0.1:1").ensure_running() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/test_hosts.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'wabot.hosts'` + +- [ ] **Step 3: Write the implementation** + +`src/wabot/hosts.py` (this file grows in Tasks 5 and 6; start with exactly this): + +```python +"""Driver hosts: the processes that keep a browser alive, and how to reach them. + +Persistence-capable browsers are always driven over the WebDriver wire +protocol (a URL). Two hosts provide that URL: ``ExternalServer`` (a +Selenium Grid or standalone driver the user runs) and ``ManagedService`` +(a chromedriver/geckodriver wabot spawns detached so it outlives Python). +""" + +from __future__ import annotations + +import json +import logging +import urllib.request + +from selenium import webdriver + +LOGGER = logging.getLogger("wabot") + +BROWSER_ALIASES = {"chrome": "chromium"} +DRIVER_BINARIES = {"chromium": "chromedriver", "firefox": "geckodriver"} + + +def normalize_browser(browser: str) -> str: + browser = BROWSER_ALIASES.get(browser, browser) + if browser not in DRIVER_BINARIES: + raise ValueError( + f"unsupported browser {browser!r}; use 'firefox' or 'chromium' (alias: 'chrome')" + ) + return browser + + +def build_options( + browser: str, *, headless: bool = False, user_agent: str | None = None +) -> webdriver.ChromeOptions | webdriver.FirefoxOptions: + """Browser-appropriate Options. UA defaults to the browser's own.""" + browser = normalize_browser(browser) + if browser == "chromium": + opts = webdriver.ChromeOptions() + binary = _chromium_binary() + if binary: + opts.binary_location = binary + if headless: + opts.add_argument("--headless=new") + if user_agent: + opts.add_argument(f"--user-agent={user_agent}") + return opts + opts = webdriver.FirefoxOptions() + if headless: + opts.add_argument("-headless") + if user_agent: + opts.set_preference("general.useragent.override", user_agent) + return opts + + +def _chromium_binary() -> str | None: + import shutil + + for name in ("chromium", "chromium-browser", "google-chrome", "chrome"): + found = shutil.which(name) + if found: + return found + return None + + +def service_alive(url: str, timeout: float = 2.0) -> bool: + """True if a WebDriver server answers GET /status at ``url``. + + Liveness only: geckodriver reports ready=false while its single session + is in use, so the ``ready`` flag is deliberately ignored. + """ + try: + with urllib.request.urlopen(f"{url.rstrip('/')}/status", timeout=timeout) as resp: + json.loads(resp.read()) + return resp.status == 200 + except (OSError, ValueError): + return False + + +class ExternalServer: + """A WebDriver server somebody else runs (Selenium Grid, bare driver).""" + + def __init__(self, url: str): + self.url = url.rstrip("/") + + def ensure_running(self) -> str: + if not service_alive(self.url): + raise ConnectionError(f"no WebDriver server answering at {self.url}/status") + return self.url +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/test_hosts.py -v` +Expected: 12 passed + +- [ ] **Step 5: Commit** + +```bash +git add src/wabot/hosts.py tests/unit/test_hosts.py +git commit -m "feat: options builder, /status liveness check, ExternalServer host" +``` + +--- +### Task 5: `hosts.py` (part 2) — ManagedService: spawn a detached driver + +**Files:** +- Modify: `src/wabot/hosts.py` (append; also extend the import block) +- Test: `tests/unit/test_hosts.py` (append) + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/unit/test_hosts.py`. The tests use a stub "driver" executable — a tiny Python script that serves `/status` on `--port=N` — so spawning/detaching/polling is tested without any browser: + +```python +import os +import signal +import stat +import sys +import time + +from wabot.hosts import ManagedService, find_driver_binary, stop_service + +STUB_DRIVER = """\ +#!{python} +import http.server, json, sys +port = int(sys.argv[1].split("=", 1)[1]) +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = json.dumps({{"value": {{"ready": True, "message": ""}}}}).encode() + self.send_response(200); self.end_headers(); self.wfile.write(body) + def log_message(self, *a): pass +http.server.HTTPServer(("127.0.0.1", port), H).serve_forever() +""" + + +@pytest.fixture +def stub_driver(tmp_path): + path = tmp_path / "chromedriver" + path.write_text(STUB_DRIVER.format(python=sys.executable)) + path.chmod(path.stat().st_mode | stat.S_IEXEC) + return path + + +class TestFindDriverBinary: + def test_env_var_override_wins(self, monkeypatch): + monkeypatch.setenv("WABOT_CHROMEDRIVER", "/custom/chromedriver") + assert find_driver_binary("chromium") == "/custom/chromedriver" + + def test_path_lookup(self, monkeypatch, stub_driver): + monkeypatch.delenv("WABOT_CHROMEDRIVER", raising=False) + monkeypatch.setenv("PATH", str(stub_driver.parent)) + assert find_driver_binary("chromium") == str(stub_driver) + + +class TestManagedService: + def test_spawns_detached_and_answers_status(self, stub_driver, tmp_path): + svc = ManagedService("chromium", log_dir=tmp_path, driver_binary=str(stub_driver)) + url = svc.ensure_running() + try: + assert service_alive(url) + assert svc.pid is not None and svc.port is not None + assert url == f"http://127.0.0.1:{svc.port}" + # detached: the spawned process is in its own session, so it + # would survive this python process exiting + assert os.getsid(svc.pid) != os.getsid(os.getpid()) + assert (tmp_path / f"chromedriver-{svc.port}.log").exists() + finally: + stop_service(svc.pid) + + def test_startup_timeout_raises(self, tmp_path): + # /bin/true exits immediately and never serves /status + svc = ManagedService( + "chromium", log_dir=tmp_path, driver_binary="/bin/true", startup_timeout=1.0 + ) + with pytest.raises(TimeoutError, match="did not answer /status"): + svc.ensure_running() + + +class TestStopService: + def test_stops_a_live_process_and_reports_dead_ones(self, stub_driver, tmp_path): + svc = ManagedService("chromium", log_dir=tmp_path, driver_binary=str(stub_driver)) + svc.ensure_running() + assert stop_service(svc.pid) is True + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + try: + os.kill(svc.pid, 0) + except ProcessLookupError: + break + time.sleep(0.05) + assert stop_service(999999) is False # no such pid +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/test_hosts.py -v` +Expected: new tests FAIL — `ImportError: cannot import name 'ManagedService'`; the 12 Task-4 tests still pass. + +- [ ] **Step 3: Write the implementation** + +Append to `src/wabot/hosts.py`, and extend the top import block to: + +```python +import json +import logging +import os +import shutil +import signal +import socket +import subprocess +import time +import urllib.request +from pathlib import Path + +import platformdirs +from selenium import webdriver +``` + +(then remove the now-redundant `import shutil` inside `_chromium_binary`). Appended code: + +```python +def find_driver_binary(browser: str) -> str: + """Locate chromedriver/geckodriver: env override, PATH, then Selenium Manager.""" + name = DRIVER_BINARIES[normalize_browser(browser)] + override = os.environ.get(f"WABOT_{name.upper()}") + if override: + return override + found = shutil.which(name) + if found: + return found + try: + # Selenium Manager is bundled with selenium but explicitly beta + # ("may change") — keep it as a guarded fallback, never the primary. + from selenium.webdriver.common.selenium_manager import SeleniumManager + + args = ["--browser", "chrome" if browser == "chromium" else browser] + chromium = _chromium_binary() + if browser == "chromium" and chromium: + args += ["--browser-path", chromium] + return SeleniumManager().binary_paths(args)["driver_path"] + except Exception as ex: # noqa: BLE001 — beta API, failure modes unknown + raise FileNotFoundError( + f"could not find {name!r} on PATH (set WABOT_{name.upper()} to override); " + f"Selenium Manager fallback also failed: {ex}" + ) from ex + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +class ManagedService: + """A driver service wabot spawns detached, so it outlives this process.""" + + def __init__( + self, + browser: str, + log_dir: Path | None = None, + driver_binary: str | None = None, + startup_timeout: float = 15.0, + ): + self.browser = normalize_browser(browser) + self.log_dir = Path(log_dir) if log_dir else Path(platformdirs.user_data_dir("wabot")) + self.driver_binary = driver_binary + self.startup_timeout = startup_timeout + self.pid: int | None = None + self.port: int | None = None + + def ensure_running(self) -> str: + binary = self.driver_binary or find_driver_binary(self.browser) + self.port = _free_port() + url = f"http://127.0.0.1:{self.port}" + self.log_dir.mkdir(parents=True, exist_ok=True) + log_path = self.log_dir / f"{DRIVER_BINARIES[self.browser]}-{self.port}.log" + with open(log_path, "ab") as log: + process = subprocess.Popen( + [binary, f"--port={self.port}"], + stdout=log, + stderr=log, + stdin=subprocess.DEVNULL, + start_new_session=True, # own session: survives this python process + ) + self.pid = process.pid + LOGGER.info("spawned %s (pid %s) on %s, log %s", binary, self.pid, url, log_path) + deadline = time.monotonic() + self.startup_timeout + while time.monotonic() < deadline: + if service_alive(url): + return url + if process.poll() is not None: + break # exited early; fall through to the error + time.sleep(0.2) + raise TimeoutError( + f"{binary} did not answer /status on {url} within " + f"{self.startup_timeout}s (see {log_path})" + ) + + +def stop_service(pid: int) -> bool: + """SIGTERM a managed driver service. False if it was already gone.""" + try: + os.kill(pid, signal.SIGTERM) + return True + except (ProcessLookupError, PermissionError): + return False +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/test_hosts.py -v` +Expected: 17 passed (12 from Task 4 + 5 new) + +- [ ] **Step 5: Commit** + +```bash +git add src/wabot/hosts.py tests/unit/test_hosts.py +git commit -m "feat: ManagedService spawns detached driver services that outlive python" +``` + +--- + +### Task 6: `hosts.py` (part 3) — ReattachingRemote + attach() + +**Files:** +- Modify: `src/wabot/hosts.py` (append) +- Test: `tests/unit/test_reattach.py` + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/test_reattach.py`. Constructing a `Remote` performs **no HTTP until the first command** (verified), so adoption can be unit-tested without a server; the dead-session path monkeypatches the probe: + +```python +import pytest +from selenium.common.exceptions import WebDriverException + +from wabot.hosts import ReattachingRemote, attach, build_options + + +class TestReattachingRemote: + def test_adopts_session_id_without_creating_a_session(self): + driver = ReattachingRemote( + "http://127.0.0.1:1", "saved-session-id", options=build_options("chromium") + ) + # base __init__ resets session_id to None BEFORE start_session runs; + # our override must still win + assert driver.session_id == "saved-session-id" + assert isinstance(driver.caps, dict) + + def test_works_for_firefox_options_too(self): + driver = ReattachingRemote( + "http://127.0.0.1:1", "sid", options=build_options("firefox") + ) + assert driver.session_id == "sid" + + +class TestAttach: + def test_returns_driver_when_probe_succeeds(self, monkeypatch): + monkeypatch.setattr( + ReattachingRemote, "current_url", property(lambda self: "http://example.com") + ) + driver = attach("http://127.0.0.1:1", "sid", "chromium") + assert driver is not None + assert driver.session_id == "sid" + + def test_returns_none_when_session_is_dead(self, monkeypatch): + def boom(self): + raise WebDriverException("invalid session id") + + monkeypatch.setattr(ReattachingRemote, "current_url", property(boom)) + assert attach("http://127.0.0.1:1", "sid", "chromium") is None + + def test_returns_none_when_server_is_unreachable(self): + # no server on port 1: connection refused is immediate; attach must not raise + assert attach("http://127.0.0.1:1", "sid", "chromium") is None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/test_reattach.py -v` +Expected: FAIL — `ImportError: cannot import name 'ReattachingRemote'` + +- [ ] **Step 3: Write the implementation** + +Append to `src/wabot/hosts.py`; add these imports to the top block: + +```python +from selenium.common.exceptions import WebDriverException +from selenium.webdriver.remote.webdriver import WebDriver as RemoteWebDriver +``` + +Appended code: + +```python +class ReattachingRemote(RemoteWebDriver): + """A Remote driver that adopts an existing session instead of creating one. + + Selenium has no supported reattach API; this relies on two internals + that have been stable across 4.x (pin selenium <5): ``execute()`` + injects ``self.session_id`` into every command, and ``start_session`` + is the only place a NEW_SESSION request happens. + """ + + def __init__(self, command_executor: str, session_id: str, options): + # NOT self.session_id: the base __init__ resets that to None before + # calling start_session, so stash the id under a private name. + self._reattach_session_id = session_id + super().__init__(command_executor=command_executor, options=options) + + def start_session(self, capabilities: dict) -> None: + # Skip Command.NEW_SESSION entirely; adopt the saved session. + self.session_id = self._reattach_session_id + self.caps = capabilities # requested caps: fine for normal commands (no CDP/BiDi) + + +def attach(executor_url: str, session_id: str, browser: str): + """Return a live driver adopting the saved session, or None if it is dead.""" + driver = ReattachingRemote(executor_url, session_id, options=build_options(browser)) + try: + _ = driver.current_url # first real HTTP call; raises if the session is gone + return driver + except WebDriverException as ex: + LOGGER.warning("saved session %s is dead: %s", session_id, ex) + return None + except OSError as ex: # server itself unreachable (connection refused, timeout) + LOGGER.warning("no server at %s: %s", executor_url, ex) + return None +``` + +Note: selenium's HTTP layer (urllib3) can surface connection failures as +`urllib3.exceptions.HTTPError` subclasses that are **not** `OSError`. If +`test_returns_none_when_server_is_unreachable` fails with an unhandled +`MaxRetryError`, add it explicitly: + +```python +from urllib3.exceptions import HTTPError as _Urllib3HTTPError +``` + +and change the last except clause to `except (OSError, _Urllib3HTTPError) as ex:`. +Whichever variant makes the test pass is the correct final code. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/test_reattach.py -v` +Expected: 5 passed + +Run: `uv run pytest` +Expected: all unit tests so far pass (pacing 6, sessions 8, hosts 17, reattach 5 = 36) + +- [ ] **Step 5: Commit** + +```bash +git add src/wabot/hosts.py tests/unit/test_reattach.py +git commit -m "feat: session reattach via ReattachingRemote (adopts saved session id)" +``` + +--- +### Task 7: `fields.py` — typed field wrappers + +**Files:** +- Create: `src/wabot/fields.py` +- Test: `tests/unit/test_fields.py` + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/test_fields.py`. Fields talk to a `page` object (which owns the driver and the form helpers), so a `MagicMock` page is the natural test double. Pacing must come from the page's browser: + +```python +from unittest.mock import MagicMock + +import pytest + +from wabot.fields import CheckField, NullField, PageObject, SelectField, TextField +from wabot.pacing import NoPacing + +ACCESSORS = ("id", "username") + + +@pytest.fixture +def page(): + page = MagicMock(name="page") + page.pacing = NoPacing() + return page + + +class TestPageObject: + def test_locates_element_via_page_driver(self, page): + obj = PageObject(page, accessors=ACCESSORS, name="username") + page.driver.find_element.assert_called_once_with(by="id", value="username") + assert obj.el is page.driver.find_element.return_value + + def test_unknown_attributes_fall_through_to_element(self, page): + obj = PageObject(page, accessors=ACCESSORS, name="username") + assert obj.tag_name is obj.el.tag_name + + def test_click_delegates_to_page(self, page): + obj = PageObject(page, accessors=ACCESSORS, name="username") + assert obj.click() is page.click.return_value + page.click.assert_called_once_with(obj.el) + + +class TestTextField: + def test_set_value_uses_page_helper(self, page): + field = TextField(page, accessors=ACCESSORS, name="username") + result = field.set_value("mathew") + page.set_el_value.assert_called_once_with(field.el, "mathew") + assert result is page.set_el_value.return_value + + def test_get_value_uses_page_helper(self, page): + field = TextField(page, accessors=ACCESSORS, name="username") + assert field.get_value() is page.get_el_value.return_value + + +class TestSelectField: + def test_wraps_element_in_selenium_select(self, page, monkeypatch): + import wabot.fields as fields_mod + + fake_select_cls = MagicMock(name="Select") + monkeypatch.setattr(fields_mod, "Select", fake_select_cls) + field = SelectField(page, accessors=("id", "state"), name="state") + fake_select_cls.assert_called_once_with(field.el) + assert field.dropdown is fake_select_cls.return_value + + def test_set_value_by_value_and_by_text(self, page, monkeypatch): + import wabot.fields as fields_mod + + monkeypatch.setattr(fields_mod, "Select", MagicMock()) + field = SelectField(page, accessors=("id", "state"), name="state") + field.set_value(value="CO") + page.set_select_value.assert_called_with(field.dropdown, value="CO", text=None) + field.set_value(text="Colorado") + page.set_select_value.assert_called_with(field.dropdown, value=None, text="Colorado") + + +class TestCheckField: + def test_set_and_get_checked(self, page): + field = CheckField(page, accessors=("id", "agree"), name="agree") + field.set_checked(True) + page.set_checkbox.assert_called_once_with(field.el, True) + field.get_checked() + page.get_checkbox_value.assert_called_once_with(field.el, False) + + +class TestNullField: + def test_is_falsy(self): + assert not NullField(name="missing") + + def test_attribute_access_raises_with_element_name(self): + with pytest.raises(AttributeError, match="missing"): + NullField(name="missing").click() + + def test_pattern_if_el_guards_work(self): + el = NullField(name="missing") + if el: + pytest.fail("NullField must be falsy") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/test_fields.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'wabot.fields'` + +- [ ] **Step 3: Write the implementation** + +`src/wabot/fields.py`: + +```python +"""Typed wrappers around page elements. + +``page[key]`` returns one of these instead of a raw WebElement. Unknown +attribute access falls through to the underlying selenium element, so a +wrapper can be used anywhere an element can. ``NullField`` is the falsy +null-object returned for elements that could not be found, enabling +``if el:`` guards instead of exception handling. +""" + +from __future__ import annotations + +import logging +import time + +from selenium.webdriver.support.ui import Select + +LOGGER = logging.getLogger("wabot") + + +class PageObject: + """Base wrapper; subclasses add typed get/set behavior.""" + + def __init__(self, page, accessors=None, name=None): + self.page = page + self.accessors = accessors + self.name = name + self.el = self._locate() if accessors else None + + def _locate(self): + by, value = self.accessors + return self.page.driver.find_element(by=by, value=value) + + @property + def pacing(self): + return self.page.pacing + + def _pace(self, action: str) -> None: + delay = self.pacing.delay(action) + if delay: + time.sleep(delay) + + def __getattr__(self, name): + if name == "el": # guard: never recurse if __init__ didn't finish + raise AttributeError(name) + return getattr(self.el, name) + + def click(self): + return self.page.click(self.el) + + def click_and_go(self): + self._pace("navigate") + return self.page.click_and_go(self.el) + + +class TextField(PageObject): + def get_value(self): + return self.page.get_el_value(self.el) + + def set_value(self, value): + LOGGER.info("[%s] set_text(%r)", self.name, value) + self._pace("text") + return self.page.set_el_value(self.el, value) + + +class SelectField(PageObject): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.dropdown = Select(self.el) + + def get_value(self): + return self.page.get_select_value(self.dropdown) + + def set_value(self, value=None, text=None): + LOGGER.info("[%s] set_select(%r)", self.name, value if value is not None else text) + self._pace("select") + return self.page.set_select_value(self.dropdown, value=value, text=text) + + def select_by_index(self, index): + self._pace("select") + return self.dropdown.select_by_index(index) + + +class CheckField(PageObject): + def get_checked(self, ignore_disabled=False): + return self.page.get_checkbox_value(self.el, ignore_disabled) + + def set_checked(self, checked): + LOGGER.info("[%s] set_checked(%r)", self.name, checked) + self._pace("checkbox") + return self.page.set_checkbox(self.el, checked) + + +class NullField: + """Falsy placeholder for an element that was not found.""" + + def __init__(self, name=None): + # bypass __getattr__ tricks: plain attribute set + object.__setattr__(self, "name", name) + + def __bool__(self): + return False + + def __getattr__(self, attr): + raise AttributeError( + f"element {self.name!r} was not found on the page (attribute {attr!r})" + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/test_fields.py -v` +Expected: 11 passed + +- [ ] **Step 5: Commit** + +```bash +git add src/wabot/fields.py tests/unit/test_fields.py +git commit -m "feat: typed field wrappers with pacing (port of legacy fields.py)" +``` + +--- + +### Task 8: `page.py` (part 1) — element maps and proxy resolution + +**Files:** +- Create: `src/wabot/page.py` +- Test: `tests/unit/test_page.py` + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/test_page.py`: + +```python +from unittest.mock import MagicMock + +from selenium.common.exceptions import NoSuchElementException + +from wabot.fields import CheckField, NullField, SelectField, TextField +from wabot.page import Page +from wabot.pacing import NoPacing + + +class BasePage(Page): + elements = { + "username": ("el", ("id", "username")), + "state": ("select", ("id", "state")), + "agree": ("checkbox", ("id", "agree")), + "rows": ("els", ("css selector", "tr.row")), + } + + +class ChildPage(BasePage): + elements = { + "username": ("el", ("id", "user_name_v2")), # override + "extra": ("el", ("id", "extra")), + } + + +def make_browser(): + browser = MagicMock(name="browser") + browser.pacing = NoPacing() + return browser + + +class TestElementResolution: + def test_own_elements_found(self): + page = BasePage(make_browser()) + assert page.find_element_locators("username") == ("el", ("id", "username")) + + def test_mro_walk_inherits_parent_elements(self): + page = ChildPage(make_browser()) + assert page.find_element_locators("state") == ("select", ("id", "state")) + assert page.find_element_locators("extra") == ("el", ("id", "extra")) + + def test_child_overrides_parent(self): + page = ChildPage(make_browser()) + assert page.find_element_locators("username") == ("el", ("id", "user_name_v2")) + + def test_missing_key_returns_none(self): + assert BasePage(make_browser()).find_element_locators("nope") is None + + +class TestGetProxy: + def test_typed_dispatch(self): + page = BasePage(make_browser()) + assert isinstance(page["username"], TextField) + assert isinstance(page["agree"], CheckField) + + def test_select_dispatch(self, monkeypatch): + import wabot.fields as fields_mod + + monkeypatch.setattr(fields_mod, "Select", MagicMock()) + page = BasePage(make_browser()) + assert isinstance(page["state"], SelectField) + + def test_els_returns_find_elements_result(self): + browser = make_browser() + page = BasePage(browser) + result = page["rows"] + browser.driver.find_elements.assert_called_once_with( + by="css selector", value="tr.row" + ) + assert result is browser.driver.find_elements.return_value + + def test_unknown_key_returns_falsy_nullfield(self): + el = BasePage(make_browser())["nope"] + assert isinstance(el, NullField) + assert not el + + def test_element_not_on_page_returns_nullfield(self): + browser = make_browser() + browser.driver.find_element.side_effect = NoSuchElementException("gone") + el = BasePage(browser)["username"] + assert isinstance(el, NullField) + + def test_custom_field_class_dispatch(self): + class MyField(TextField): + pass + + class CustomPage(Page): + elements = {"thing": (MyField, ("id", "thing"))} + + assert isinstance(CustomPage(make_browser())["thing"], MyField) + + +class TestVerify: + def test_default_verify_is_true(self): + assert BasePage(make_browser()).verify() is True +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/test_page.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'wabot.page'` + +- [ ] **Step 3: Write the implementation** + +`src/wabot/page.py` (interaction helpers arrive in Task 9; start with exactly this): + +```python +"""Page: the base class consumers subclass to model a website page. + +Pages declare a class-level ``elements`` map:: + + class Login(wabot.Page): + elements = { + "username": ("el", ("id", "username")), + "state": ("select", ("id", "state")), + "agree": ("checkbox", ("id", "agree")), + "rows": ("els", ("css selector", "tr.row")), + "special": (MyFieldClass, ("id", "special")), + } + +Lookup walks the MRO, so subclasses inherit and override parent maps. +``page[key]`` returns a typed field wrapper — never a raw WebElement — +and a falsy ``NullField`` when the element cannot be found. +""" + +from __future__ import annotations + +import logging + +from selenium.common.exceptions import NoSuchElementException + +from .fields import CheckField, NullField, SelectField, TextField + +LOGGER = logging.getLogger("wabot") + +ALERT_TIMEOUT = 3 +PAGE_LOAD_TIMEOUT = 10 + +FIELD_TYPES = {"el": TextField, "select": SelectField, "checkbox": CheckField} + + +class Page: + elements: dict = {} + + def __init__(self, browser): + self.browser = browser + self.driver = browser.driver + LOGGER.info("loaded page %s", type(self).__name__) + + @property + def pacing(self): + return self.browser.pacing + + def verify(self) -> bool: + """Override to gate ``Browser.set_page`` (return False to refuse).""" + return True + + def find_element_locators(self, key): + """First match for ``key`` walking the class hierarchy (MRO order).""" + for cls in type(self).__mro__: + locators = getattr(cls, "elements", {}).get(key) + if locators: + return locators + return None + + def get_proxy(self, key): + locators = self.find_element_locators(key) + if not locators: + LOGGER.warning("element not in page map: %s", key) + return NullField(name=key) + obj_type, accessors = locators[0], locators[1] + try: + if obj_type == "els": + by, value = accessors + return self.driver.find_elements(by=by, value=value) + field_cls = FIELD_TYPES.get(obj_type) + if field_cls is None and isinstance(obj_type, type): + field_cls = obj_type + if field_cls is not None: + return field_cls(page=self, accessors=accessors, name=key) + except NoSuchElementException: + LOGGER.warning("element %r not present on current page", key) + return NullField(name=key) + LOGGER.error("unknown element type for %r: %r", key, obj_type) + return NullField(name=key) + + def __getitem__(self, key): + return self.get_proxy(key) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/test_page.py -v` +Expected: 12 passed + +- [ ] **Step 5: Commit** + +```bash +git add src/wabot/page.py tests/unit/test_page.py +git commit -m "feat: Page element maps with MRO inheritance and typed dispatch" +``` + +--- + +### Task 9: `page.py` (part 2) — clicks, forms, alerts, waits + +**Files:** +- Modify: `src/wabot/page.py` (append methods to `Page`; extend imports) +- Test: `tests/unit/test_page.py` (append) + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/unit/test_page.py`: + +```python +import wabot.page as page_mod +from wabot.pacing import HumanPacing + + +class PlainPage(Page): + elements = {} + + +def make_element(width=100, height=30, displayed=True, enabled=True): + el = MagicMock(name="element") + el.size = {"width": width, "height": height} + el.is_displayed.return_value = displayed + el.is_enabled.return_value = enabled + return el + + +class TestClick: + def test_refuses_null_element(self): + assert PlainPage(make_browser()).click(None) is False + assert PlainPage(make_browser()).click(NullField(name="x")) is False + + def test_plain_click_when_pacing_gives_no_offset(self): + page = PlainPage(make_browser()) # NoPacing -> click_offset None + el = make_element() + assert page.click(el) is True + el.click.assert_called_once_with() + + def test_offset_click_uses_action_chains(self, monkeypatch): + browser = make_browser() + browser.pacing = HumanPacing() + chains = MagicMock(name="ActionChains") + monkeypatch.setattr(page_mod, "ActionChains", chains) + page = PlainPage(browser) + el = make_element(width=100, height=40) + assert page.click(el) is True + chains.assert_called_once_with(browser.driver) + args = chains.return_value.move_to_element_with_offset.call_args.args + assert args[0] is el + # offsets are center-relative and must stay inside the element + assert -49 <= args[1] <= 49 and -19 <= args[2] <= 19 + chains.return_value.move_to_element_with_offset.return_value.click.return_value.perform.assert_called_once() + + def test_never_clickable_element_fails(self): + page = PlainPage(make_browser()) + el = make_element(displayed=False) + assert page.click(el, clickable_timeout=0.3) is False + el.click.assert_not_called() + + +class TestFormHelpers: + def test_set_el_value_clears_types_and_verifies(self): + page = PlainPage(make_browser()) + el = make_element() + el.get_attribute.return_value = "mathew" + assert page.set_el_value(el, "mathew") is True + el.clear.assert_called_once_with() + el.send_keys.assert_called_once_with("mathew") + + def test_set_el_value_returns_false_on_mismatch(self): + page = PlainPage(make_browser()) + el = make_element() + el.get_attribute.return_value = "wrong" + assert page.set_el_value(el, "mathew") is False + + def test_set_el_value_none_just_clears(self): + page = PlainPage(make_browser()) + el = make_element() + assert page.set_el_value(el, None) is True + el.clear.assert_called_once_with() + el.send_keys.assert_not_called() + + def test_get_el_value_and_text_handle_null(self): + page = PlainPage(make_browser()) + assert page.get_el_value(None) is None + assert page.get_el_text(None) is None + + def test_set_checkbox_clicks_only_when_state_differs(self): + page = PlainPage(make_browser()) + el = make_element() + el.is_selected.return_value = False + page.set_checkbox(el, True) + el.click.assert_called_once() + el.click.reset_mock() + el.is_selected.return_value = True + page.set_checkbox(el, True) + el.click.assert_not_called() + + +class TestStaleness: + def test_fresh_element_is_not_stale(self): + page = PlainPage(make_browser()) + assert page.is_element_stale(make_element()) is False + + def test_stale_element_is_detected(self): + from selenium.common.exceptions import StaleElementReferenceException + + class StaleEl: # NOT a MagicMock: mutating type(mock) would poison every mock + @property + def tag_name(self): + raise StaleElementReferenceException("stale") + + page = PlainPage(make_browser()) + assert page.is_element_stale(StaleEl()) is True +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/test_page.py -v` +Expected: new tests FAIL with `AttributeError: 'PlainPage' object has no attribute 'click'`; Task-8 tests still pass. + +- [ ] **Step 3: Write the implementation** + +Extend the import block of `src/wabot/page.py` to: + +```python +import logging +import time + +from selenium.common.exceptions import ( + NoSuchElementException, + StaleElementReferenceException, + TimeoutException, + WebDriverException, +) +from selenium.webdriver import ActionChains +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait + +from .fields import CheckField, NullField, SelectField, TextField +``` + +(`screenshot.py` is deliberately NOT imported at module level — it doesn't exist until Task 10; `save_screenshot` below imports it lazily. Create the placeholder now so that lazy import resolves. `src/wabot/screenshot.py`:) + +```python +"""Full-page screenshots. Implemented in the screenshot task.""" +``` + +Append these methods to `class Page`: + +```python + # ---- clicking ----------------------------------------------------- + + def click(self, el, clickable_timeout: float = 10.0) -> bool: + """Click, at a pacing-chosen offset when the policy asks for one.""" + if not el: + LOGGER.warning("refusing to click null element") + return False + try: + size = el.size + except StaleElementReferenceException: + LOGGER.error("failed to click element: stale reference") + return False + if not self._wait_clickable(el, clickable_timeout): + return False + offset = self.pacing.click_offset(size["width"], size["height"]) + try: + if offset is None: + el.click() + else: + x, y = offset # measured from element center (selenium 4) + ActionChains(self.driver).move_to_element_with_offset(el, x, y).click().perform() + return True + except WebDriverException as ex: + LOGGER.error("click failed: %s", ex) + return False + + def _wait_clickable(self, el, timeout: float) -> bool: + deadline = time.monotonic() + timeout + while True: + try: + if el.is_displayed() and el.is_enabled(): + return True + except StaleElementReferenceException: + return False + if time.monotonic() >= deadline: + LOGGER.error("element never became clickable") + return False + time.sleep(0.2) + + def click_and_go(self, el) -> bool: + """Click, accept any alert, then wait for the page to change.""" + if not self.click(el): + return False + self.accept_alert() + return self._wait_for_element_to_go_stale(el) + + def _wait_for_element_to_go_stale(self, el) -> bool: + try: + WebDriverWait(self.driver, PAGE_LOAD_TIMEOUT).until( + lambda _driver: self.is_element_stale(el) + ) + return True + except TimeoutException: + LOGGER.error("timed out waiting for page load") + return False + + def is_element_stale(self, el) -> bool: + try: + _ = el.tag_name + return False + except StaleElementReferenceException: + return True + + # ---- alerts ------------------------------------------------------- + + def accept_alert(self, accept: bool = True, timeout: float = ALERT_TIMEOUT): + """Accept (or dismiss) a JS alert if one appears within ``timeout``. + + Returns the alert text, or False if no alert appeared. There is a + timeout penalty when no alert exists. + """ + try: + WebDriverWait(self.driver, timeout).until(EC.alert_is_present()) + except TimeoutException: + LOGGER.debug("no js alert present") + return False + alert = self.driver.switch_to.alert + text = alert.text + if accept: + alert.accept() + else: + alert.dismiss() + LOGGER.debug("handled js alert: %s", text) + return text + + # ---- form helpers ------------------------------------------------- + + def get_el_value(self, el): + if not el: + return None + return el.get_attribute("value") + + def get_el_text(self, el): + if not el: + return None + return el.text + + def set_el_value(self, el, value, slow_type: bool = False) -> bool: + if not el: + return False + el.clear() + if value is None: + return True + try: + if slow_type: + for char in str(value): + el.send_keys(char) + time.sleep(self.pacing.delay("text") / 10.0) + else: + el.send_keys(value) + except WebDriverException as ex: + LOGGER.error("failed to send keys, element in unknown state: %s", ex) + return False + actual = self.get_el_value(el) + if str(actual) != str(value): + LOGGER.error("field value mismatch: wanted %r, field has %r", value, actual) + return False + return True + + def get_select_value(self, select): + if not select: + LOGGER.error("tried to get select value of null element") + return None + try: + return select.first_selected_option.get_attribute("value") + except NoSuchElementException: + return None + + def set_select_value(self, select, value=None, text=None) -> bool: + if not select: + return False + try: + if value is not None: + select.select_by_value(str(value)) + return True + if text is not None: + select.select_by_visible_text(text) + return True + except (NoSuchElementException, WebDriverException) as ex: + LOGGER.error("failed to set select: %s", ex) + return False + + def set_checkbox(self, el, checked: bool) -> bool: + if not el: + return False + if not el.is_enabled(): + return False + if el.is_selected() != checked: + self.click(el) + return True + + def get_checkbox_value(self, el, ignore_disabled: bool = False): + """True if (enabled and) checked; None for a null element.""" + if not el: + return None + return (ignore_disabled or el.is_enabled()) and el.is_selected() + + def get_el_identifier(self, el): + """A quick human-readable identifier for logging.""" + for attr in ("id", "name", "class"): + value = el.get_attribute(attr) + if value: + return value + return el.tag_name + + # ---- screenshots ---------------------------------------------------- + + def save_screenshot(self, filename: str) -> bool: + from . import screenshot as screenshot_mod + + LOGGER.info("saving full-page screenshot: %s", filename) + return screenshot_mod.save_full_page(self.driver, filename) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/test_page.py -v` +Expected: 23 passed (12 from Task 8 + 11 new) + +- [ ] **Step 5: Commit** + +```bash +git add src/wabot/page.py src/wabot/screenshot.py tests/unit/test_page.py +git commit -m "feat: Page interaction helpers (paced clicks, forms, alerts, waits)" +``` + +--- +### Task 10: `screenshot.py` — full-page screenshots + +**Files:** +- Modify: `src/wabot/screenshot.py` (replace the Task-9 placeholder) +- Test: `tests/unit/test_screenshot.py` + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/test_screenshot.py`: + +```python +import base64 +from io import BytesIO +from unittest.mock import MagicMock + +from PIL import Image + +from wabot.screenshot import FULL_PAGE_SCREENSHOT, save_full_page + + +def png_bytes(width, height, color=(200, 30, 30)): + buf = BytesIO() + Image.new("RGB", (width, height), color).save(buf, format="PNG") + return buf.getvalue() + + +class TestFirefoxPath: + def test_registers_raw_command_and_decodes_base64(self, tmp_path): + driver = MagicMock(name="driver") + driver.capabilities = {"browserName": "firefox"} + driver.command_executor._commands = {} + driver.execute.return_value = {"value": base64.b64encode(png_bytes(50, 80)).decode()} + out = tmp_path / "shot.png" + assert save_full_page(driver, str(out)) is True + # helper methods only exist on webdriver.Firefox, so the raw + # geckodriver command must have been registered for Remote support + assert driver.command_executor._commands[FULL_PAGE_SCREENSHOT] == ( + "GET", + "/session/$sessionId/moz/screenshot/full", + ) + driver.execute.assert_called_once_with(FULL_PAGE_SCREENSHOT) + assert Image.open(out).size == (50, 80) + + +class TestChromiumPath: + def test_single_viewport_page_is_saved_directly(self, tmp_path): + driver = MagicMock(name="driver") + driver.capabilities = {"browserName": "chrome"} + # page fits in one viewport: total == viewport + driver.execute_script.side_effect = lambda script: { + "return document.body.parentNode.scrollWidth": 100, + "return document.body.parentNode.scrollHeight": 60, + "return document.documentElement.clientWidth": 100, + "return window.innerHeight": 60, + }.get(script, None) + driver.get_screenshot_as_png.return_value = png_bytes(100, 60) + out = tmp_path / "shot.png" + assert save_full_page(driver, str(out)) is True + assert Image.open(out).size == (100, 60) + + def test_tall_page_is_stitched_from_tiles(self, tmp_path): + driver = MagicMock(name="driver") + driver.capabilities = {"browserName": "chrome"} + driver.execute_script.side_effect = lambda script: { + "return document.body.parentNode.scrollWidth": 100, + "return document.body.parentNode.scrollHeight": 150, # 3 tiles of 60 + "return document.documentElement.clientWidth": 100, + "return window.innerHeight": 60, + }.get(script, None) + driver.get_screenshot_as_png.return_value = png_bytes(100, 60) + out = tmp_path / "shot.png" + assert save_full_page(driver, str(out)) is True + assert Image.open(out).size == (100, 150) + # scrolled at least twice beyond the initial position + scroll_calls = [ + c for c in driver.execute_script.call_args_list + if c.args and str(c.args[0]).startswith("window.scrollTo") + ] + assert len(scroll_calls) >= 2 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/test_screenshot.py -v` +Expected: FAIL — `ImportError: cannot import name 'FULL_PAGE_SCREENSHOT'` + +- [ ] **Step 3: Write the implementation** + +Replace `src/wabot/screenshot.py` entirely: + +```python +"""Full-page screenshots for both browser families. + +Firefox: geckodriver has a native endpoint, but selenium only exposes it +on ``webdriver.Firefox`` — not on ``Remote`` — so the raw command is +registered by hand and works for both. + +Chromium: no full-page endpoint exists; the page is captured viewport by +viewport while scrolling, then stitched with PIL (port of the legacy +workaround, using in-memory PNGs instead of temp files). +""" + +from __future__ import annotations + +import base64 +import logging +import time +from io import BytesIO + +from PIL import Image + +LOGGER = logging.getLogger("wabot") + +FULL_PAGE_SCREENSHOT = "fullPageScreenshot" +_SCROLL_SETTLE_SECONDS = 0.2 + + +def save_full_page(driver, filename: str) -> bool: + """Save a full-page PNG of the current page to ``filename``.""" + if driver.capabilities.get("browserName") == "firefox": + return _firefox_full_page(driver, filename) + return _stitch_chromium(driver, filename) + + +def _firefox_full_page(driver, filename: str) -> bool: + driver.command_executor._commands.setdefault( + FULL_PAGE_SCREENSHOT, ("GET", "/session/$sessionId/moz/screenshot/full") + ) + b64 = driver.execute(FULL_PAGE_SCREENSHOT)["value"] + with open(filename, "wb") as fp: + fp.write(base64.b64decode(b64)) + return True + + +def _stitch_chromium(driver, filename: str) -> bool: + total_width = driver.execute_script("return document.body.parentNode.scrollWidth") + total_height = driver.execute_script("return document.body.parentNode.scrollHeight") + viewport_width = driver.execute_script("return document.documentElement.clientWidth") + viewport_height = driver.execute_script("return window.innerHeight") + + if total_width <= viewport_width and total_height <= viewport_height: + with open(filename, "wb") as fp: + fp.write(driver.get_screenshot_as_png()) + return True + + stitched = Image.new("RGB", (total_width, total_height)) + y = 0 + while y < total_height: + x = 0 + while x < total_width: + driver.execute_script(f"window.scrollTo({x}, {y})") + time.sleep(_SCROLL_SETTLE_SECONDS) + tile = Image.open(BytesIO(driver.get_screenshot_as_png())) + # the last row/column can't scroll a full viewport: paste aligned + # to the bottom/right edge instead of duplicating content + paste_x = min(x, total_width - viewport_width) + paste_y = min(y, total_height - viewport_height) + stitched.paste(tile, (max(paste_x, 0), max(paste_y, 0))) + x += viewport_width + y += viewport_height + stitched.save(filename) + LOGGER.debug("stitched %sx%s screenshot -> %s", total_width, total_height, filename) + return True +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/test_screenshot.py -v` +Expected: 3 passed. Also run `uv run pytest` — all unit tests still pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/wabot/screenshot.py tests/unit/test_screenshot.py +git commit -m "feat: full-page screenshots (firefox raw command, chromium PIL stitch)" +``` + +--- + +### Task 11: `browser.py` — the Browser facade + +**Files:** +- Create: `src/wabot/browser.py` +- Test: `tests/unit/test_browser.py` + +Naming note: the spec says "`reset_good_status()` retained"; under the approved free-API-redesign decision it is renamed to the cleaner `reset()` (same semantics: clears the broken-state flag). This is the only intentional naming deviation from the spec. + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/test_browser.py`: + +```python +from unittest.mock import MagicMock + +import pytest +from selenium.common.exceptions import WebDriverException + +from wabot.browser import Browser +from wabot.pacing import HumanPacing, NoPacing +from wabot.page import Page + + +class Login(Page): + elements = {"username": ("el", ("id", "username"))} + + def do_login(self): + return "logged-in" + + def explode(self): + raise WebDriverException("browser gone") + + +class Unverifiable(Page): + def verify(self): + return False + + +@pytest.fixture +def bot(): + return Browser(MagicMock(name="driver"), pacing=NoPacing()) + + +class TestConstruction: + def test_good_flag_starts_true(self, bot): + assert bot.good is True + + def test_default_pacing_is_human(self): + assert isinstance(Browser(MagicMock()).pacing, HumanPacing) + + +class TestSetPage: + def test_sets_and_returns_true(self, bot): + assert bot.set_page(Login) is True + assert isinstance(bot.page, Login) + + def test_failed_verify_refuses_switch(self, bot): + bot.set_page(Login) + assert bot.set_page(Unverifiable) is False + assert isinstance(bot.page, Login) # unchanged + + +class TestDelegation: + def test_getattr_delegates_to_page(self, bot): + bot.set_page(Login) + assert bot.do_login() == "logged-in" + + def test_getitem_delegates_to_page(self, bot): + bot.set_page(Login) + el = bot["username"] + assert el.name == "username" + + def test_getattr_without_page_raises(self, bot): + with pytest.raises(AttributeError): + bot.do_login + + +class TestPerform: + def test_returns_method_result(self, bot): + bot.set_page(Login) + assert bot.perform("do_login") == "logged-in" + + def test_missing_method_returns_none(self, bot): + bot.set_page(Login) + assert bot.perform("nope") is None + + def test_webdriver_exception_flips_good(self, bot): + bot.set_page(Login) + assert bot.perform("explode") is None + assert bot.good is False + + def test_broken_state_refuses_actions_until_reset(self, bot): + bot.set_page(Login) + bot.perform("explode") + assert bot.perform("do_login") is None # refused + assert bot.do_login() is None # __getattr__ path also refused + bot.reset() + assert bot.perform("do_login") == "logged-in" + + +class TestQuit: + def test_quit_quits_driver_and_removes_session(self): + driver, store = MagicMock(), MagicMock() + bot = Browser(driver, session_name="s1", store=store) + bot.quit() + driver.quit.assert_called_once_with() + store.remove.assert_called_once_with("s1") + + def test_quit_without_session_only_quits(self): + driver = MagicMock() + Browser(driver).quit() + driver.quit.assert_called_once_with() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/test_browser.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'wabot.browser'` + +- [ ] **Step 3: Write the implementation** + +`src/wabot/browser.py`: + +```python +"""Browser: the facade consumers drive. + +Holds the webdriver plus a *current page* object. Unknown attribute +access is delegated to the current page (``bot.login()`` invokes +``bot.page.login()``); ``bot[key]`` resolves elements on it. Any +``perform()`` failure flips ``good = False``, after which page actions +are refused until ``reset()`` — "log and refuse, don't crash". +""" + +from __future__ import annotations + +import logging + +from selenium.common.exceptions import WebDriverException + +from .pacing import HumanPacing + +LOGGER = logging.getLogger("wabot") + +# attributes that must never be delegated to the page +_OWN_ATTRS = frozenset( + {"driver", "pacing", "session_name", "good", "page", "_store"} +) + + +def _refused(*_args, **_kwargs): + return None + + +class Browser: + def __init__(self, driver, *, pacing=None, session_name=None, store=None): + self.driver = driver + self.pacing = pacing if pacing is not None else HumanPacing() + self.session_name = session_name + self._store = store + self.good = True + self.page = None + + def set_page(self, page_cls) -> bool: + """Instantiate ``page_cls`` and make it current if its verify() passes.""" + page = page_cls(self) + if not page.verify(): + LOGGER.error("failed to verify page: %s", page_cls.__name__) + return False + self.page = page + return True + + def __getattr__(self, name): + if name in _OWN_ATTRS or name.startswith("__"): + raise AttributeError(name) + if self.page is None: + raise AttributeError( + f"{name!r}: no current page — call set_page() first" + ) + if not self.good: + LOGGER.warning("broken state — refusing page action %r (call reset())", name) + return _refused + return getattr(self.page, name) + + def __getitem__(self, key): + return self.page[key] + + def perform(self, method: str, *args, **kwargs): + """Invoke a page method, trapping failures instead of raising.""" + if not self.good: + LOGGER.warning("broken state — refusing %r (call reset())", method) + return None + try: + fn = getattr(self.page, method) + except AttributeError: + LOGGER.error("page %s has no action %r", type(self.page).__name__, method) + return None + try: + return fn(*args, **kwargs) + except WebDriverException: + LOGGER.exception("page action %r failed; flipping good=False", method) + self.good = False + return None + + def reset(self) -> None: + """Clear the broken-state flag after an exception.""" + self.good = True + + def quit(self) -> None: + """Quit the browser; forget the saved session if there is one.""" + try: + self.driver.quit() + finally: + if self._store is not None and self.session_name: + self._store.remove(self.session_name) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/test_browser.py -v` +Expected: 13 passed + +- [ ] **Step 5: Commit** + +```bash +git add src/wabot/browser.py tests/unit/test_browser.py +git commit -m "feat: Browser facade with working refuse-after-exception guard" +``` + +--- + +### Task 12: `__init__.py` — public API: browser(), sessions(), destroy() + +**Files:** +- Modify: `src/wabot/__init__.py` +- Test: `tests/unit/test_api.py` + +- [ ] **Step 1: Write the failing tests** + +`tests/unit/test_api.py`. All webdriver construction is monkeypatched — these tests exercise the wiring, not selenium: + +```python +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 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/test_api.py -v` +Expected: FAIL — `AttributeError: module 'wabot' has no attribute 'browser'` + +- [ ] **Step 3: Write the implementation** + +Replace `src/wabot/__init__.py` entirely: + +```python +"""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 .page import Page +from .pacing import HumanPacing, NoPacing +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 +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/test_api.py -v` +Expected: 11 passed + +Run: `uv run pytest` +Expected: full unit suite passes (97 tests: pacing 6, sessions 8, hosts 17, reattach 5, fields 11, page 23, screenshot 3, browser 13, api 11) + +- [ ] **Step 5: Commit** + +```bash +git add src/wabot/__init__.py tests/unit/test_api.py +git commit -m "feat: public API — wabot.browser()/sessions()/destroy() with reattach flow" +``` + +--- +### Task 13: Integration fixtures — static HTML + local server + conftest + +**Files:** +- Create: `tests/fixtures/login.html`, `tests/fixtures/welcome.html` +- Create: `tests/integration/conftest.py` + +- [ ] **Step 1: Create the HTML fixtures** + +`tests/fixtures/login.html`: + +```html + + +Login + +

Test Login

+
+ + + + +
+ + +``` + +`tests/fixtures/welcome.html`: + +```html + + +Welcome + +

Welcome!

+

You made it.

+ + +``` + +- [ ] **Step 2: Write the conftest** + +`tests/integration/conftest.py`: + +```python +"""Shared fixtures for real-browser integration tests. + +Every test in this directory is marked ``integration`` automatically and +runs with NoPacing (human delays would make the suite glacial). Browsers +run headless against static HTML served from tests/fixtures. +""" + +import functools +import http.server +import threading +from pathlib import Path + +import pytest + +FIXTURES = Path(__file__).parent.parent / "fixtures" +BROWSERS = ["firefox", "chromium"] + + +def pytest_collection_modifyitems(items): + for item in items: + item.add_marker(pytest.mark.integration) + + +@pytest.fixture(scope="session") +def site_url(): + """Serve tests/fixtures over HTTP on an ephemeral port.""" + handler = functools.partial( + http.server.SimpleHTTPRequestHandler, directory=str(FIXTURES) + ) + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{server.server_address[1]}" + server.shutdown() + + +@pytest.fixture +def store(tmp_path): + from wabot.sessions import SessionStore + + return SessionStore(path=tmp_path / "sessions.json") +``` + +- [ ] **Step 3: Verify collection behaves** + +Run: `uv run pytest --collect-only tests/integration` +Expected: collects 0 tests so far, no errors. + +Run: `uv run pytest` +Expected: unit suite unchanged; integration dir contributes nothing yet. + +- [ ] **Step 4: Commit** + +```bash +git add tests/fixtures tests/integration/conftest.py +git commit -m "test: integration fixtures — static site server, auto-marking conftest" +``` + +--- + +### Task 14: Integration — ephemeral browsing through the page-object API + +**Files:** +- Create: `tests/integration/test_ephemeral.py` + +- [ ] **Step 1: Write the test** + +`tests/integration/test_ephemeral.py`: + +```python +"""Drive the full page-object stack against a real headless browser.""" + +import pytest + +import wabot + +from .conftest import BROWSERS + + +class LoginPage(wabot.Page): + elements = { + "title": ("el", ("id", "page-title")), + "username": ("el", ("id", "username")), + "state": ("select", ("id", "state")), + "agree": ("checkbox", ("id", "agree")), + "submit": ("el", ("id", "submit-btn")), + } + + def verify(self): + return self.driver.title == "Login" + + def log_in(self, username): + self["username"].set_value(username) + self["state"].set_value(value="CO") + self["agree"].set_checked(True) + return self["submit"].click_and_go() + + +class WelcomePage(wabot.Page): + elements = {"greeting": ("el", ("id", "greeting"))} + + def verify(self): + return self.driver.title == "Welcome" + + +@pytest.mark.parametrize("browser_name", BROWSERS) +def test_login_flow_end_to_end(browser_name, site_url): + bot = wabot.browser(browser=browser_name, headless=True, pacing=wabot.NoPacing()) + try: + bot.driver.get(f"{site_url}/login.html") + assert bot.set_page(LoginPage) is True + assert bot.log_in("mathew") is True + assert bot.set_page(WelcomePage) is True + assert bot["greeting"].text == "You made it." + assert bot.good is True + finally: + bot.driver.quit() + + +@pytest.mark.parametrize("browser_name", BROWSERS) +def test_full_page_screenshot(browser_name, site_url, tmp_path): + bot = wabot.browser(browser=browser_name, headless=True, pacing=wabot.NoPacing()) + try: + bot.driver.get(f"{site_url}/login.html") + bot.set_page(LoginPage) + out = tmp_path / "shot.png" + assert bot.save_screenshot(str(out)) is True + assert out.stat().st_size > 0 + finally: + bot.driver.quit() +``` + +- [ ] **Step 2: Run the integration tests** + +Run: `uv run pytest -m integration tests/integration/test_ephemeral.py -v` +Expected: 4 passed (firefox + chromium × 2 tests). First firefox run may download geckodriver via Selenium Manager (network). If a browser is missing on the machine, that parametrization may error — investigate before proceeding; do not mark xfail. + +Run: `uv run pytest` +Expected: unit suite only (integration deselected by default). + +- [ ] **Step 3: Commit** + +```bash +git add tests/integration/test_ephemeral.py +git commit -m "test: end-to-end page-object flow on real headless browsers" +``` + +--- + +### Task 15: Integration — the flagship: cross-process session pickup + +**Files:** +- Create: `tests/integration/test_persistence.py` + +- [ ] **Step 1: Write the test** + +`tests/integration/test_persistence.py`: + +```python +"""The flagship feature: a second Python PROCESS picks up a saved browser.""" + +import json +import subprocess +import sys +import textwrap + +import pytest + +import wabot + +from .conftest import BROWSERS + +REATTACH_SCRIPT = textwrap.dedent( + """ + import json, sys + import wabot + from wabot.sessions import SessionStore + + store = SessionStore(path=sys.argv[1]) + bot = wabot.browser(session=sys.argv[2], pacing=wabot.NoPacing(), store=store) + value = bot.driver.find_element("id", "username").get_attribute("value") + print(json.dumps({"url": bot.driver.current_url, "username_value": value})) + """ +) + + +@pytest.mark.parametrize("browser_name", BROWSERS) +def test_second_process_picks_up_saved_browser(browser_name, site_url, store, tmp_path): + bot = wabot.browser( + session="flagship", browser=browser_name, headless=True, + pacing=wabot.NoPacing(), store=store, + ) + try: + bot.driver.get(f"{site_url}/login.html") + bot.driver.find_element("id", "username").send_keys("persisted-value") + + # simulate "the process exits": drop our handle without quitting + record = store.get("flagship") + assert record is not None and record.service_pid is not None + + result = subprocess.run( + [sys.executable, "-c", REATTACH_SCRIPT, str(store.path), "flagship"], + capture_output=True, text=True, timeout=120, check=True, + ) + payload = json.loads(result.stdout.strip().splitlines()[-1]) + assert payload["url"] == f"{site_url}/login.html" + assert payload["username_value"] == "persisted-value" + finally: + assert wabot.destroy("flagship", store=store) is True + + +def test_destroyed_session_is_really_gone(site_url, store): + from wabot.hosts import service_alive + + bot = wabot.browser( + session="doomed", browser="chromium", headless=True, + pacing=wabot.NoPacing(), store=store, + ) + bot.driver.get(f"{site_url}/login.html") + record = store.get("doomed") + assert wabot.destroy("doomed", store=store) is True + assert store.get("doomed") is None + assert wabot.destroy("doomed", store=store) is False + # the managed driver service was SIGTERMed + import time + + deadline = time.monotonic() + 10 + while time.monotonic() < deadline and service_alive(record.executor_url): + time.sleep(0.2) + assert not service_alive(record.executor_url) +``` + +- [ ] **Step 2: Run the tests** + +Run: `uv run pytest -m integration tests/integration/test_persistence.py -v` +Expected: 3 passed. This proves: managed service spawned detached → session recorded as JSON → a *fresh Python process* reattached by name and saw the same page **and the typed-in field value** → destroy() cleaned everything up. + +- [ ] **Step 3: Commit** + +```bash +git add tests/integration/test_persistence.py +git commit -m "test: flagship cross-process browser pickup + destroy cleanup" +``` + +--- + +### Task 16: Integration — external-server mode + +**Files:** +- Create: `tests/integration/test_external_server.py` + +- [ ] **Step 1: Write the test** + +A manually spawned chromedriver plays the "external Selenium server the user runs" — same wire protocol, no Java Grid needed: + +`tests/integration/test_external_server.py`: + +```python +"""host= mode: wabot connects to a WebDriver server it does not manage.""" + +import shutil +import subprocess +import time + +import pytest + +import wabot +from wabot.hosts import service_alive + + +@pytest.fixture +def external_chromedriver(): + binary = shutil.which("chromedriver") + if binary is None: + pytest.skip("chromedriver not on PATH") + proc = subprocess.Popen( + [binary, "--port=19515"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + url = "http://127.0.0.1:19515" + deadline = time.monotonic() + 15 + while time.monotonic() < deadline and not service_alive(url): + time.sleep(0.2) + assert service_alive(url), "chromedriver never came up" + yield url + proc.terminate() + proc.wait(timeout=10) + + +def test_persistent_session_on_external_host(external_chromedriver, site_url, store): + bot = wabot.browser( + session="ext", browser="chromium", host=external_chromedriver, + headless=True, pacing=wabot.NoPacing(), store=store, + ) + bot.driver.get(f"{site_url}/welcome.html") + record = store.get("ext") + assert record.executor_url == external_chromedriver + assert record.service_pid is None # not ours to kill + + # same process, second browser() call: must reattach, not create + bot2 = wabot.browser(session="ext", store=store, pacing=wabot.NoPacing()) + assert bot2.driver.session_id == record.session_id + assert bot2.driver.current_url == f"{site_url}/welcome.html" + + bot2.driver.quit() + store.remove("ext") + + +def test_ephemeral_on_external_host(external_chromedriver, site_url, store): + bot = wabot.browser( + host=external_chromedriver, browser="chromium", + headless=True, pacing=wabot.NoPacing(), store=store, + ) + bot.driver.get(f"{site_url}/welcome.html") + assert store.names() == [] # session=None never persists + bot.driver.quit() +``` + +- [ ] **Step 2: Run the tests** + +Run: `uv run pytest -m integration tests/integration/test_external_server.py -v` +Expected: 2 passed + +Run: `uv run pytest -m integration` +Expected: entire integration suite passes (9 tests) + +- [ ] **Step 3: Commit** + +```bash +git add tests/integration/test_external_server.py +git commit -m "test: external WebDriver server mode (persist + ephemeral)" +``` + +--- + +### Task 17: Sphinx documentation + +**Files:** +- Create: `docs/conf.py`, `docs/index.rst`, `docs/quickstart.rst`, `docs/sessions.rst`, `docs/page-objects.rst`, `docs/api.rst` + +- [ ] **Step 1: Write the Sphinx config** + +`docs/conf.py`: + +```python +project = "wabot" +author = "Mathew Guest" +release = "0.2.0" + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", +] + +# sphinx_rtd_theme registers itself via entry point — do NOT add to extensions +html_theme = "sphinx_rtd_theme" + +autodoc_member_order = "bysource" +napoleon_google_docstring = True +``` + +(No `sys.path` hack needed: `uv run sphinx-build` runs inside the synced venv where wabot is installed.) + +- [ ] **Step 2: Write the doc pages** + +`docs/index.rst`: + +```rst +wabot +===== + +Stateful Selenium browser automation with sessions that survive the +Python process. + +.. toctree:: + :maxdepth: 2 + + quickstart + sessions + page-objects + api +``` + +`docs/quickstart.rst`: + +```rst +Quickstart +========== + +Install +------- + +.. code-block:: bash + + uv add wabot # or: pip install . + +You also need Firefox or Chromium. Driver binaries (geckodriver / +chromedriver) are found on PATH or downloaded automatically by Selenium +Manager. + +First scrape +------------ + +.. code-block:: python + + import wabot + + class Login(wabot.Page): + elements = { + "username": ("el", ("id", "username")), + "password": ("el", ("id", "password")), + "submit": ("el", ("id", "submit-btn")), + } + + def verify(self): + return "Login" in self.driver.title + + def log_in(self, user, password): + self["username"].set_value(user) + self["password"].set_value(password) + return self["submit"].click_and_go() + + bot = wabot.browser(browser="firefox", headless=True) + bot.driver.get("https://example.com/login") + bot.set_page(Login) + bot.log_in("me", "secret") + +By default wabot paces itself like a human (multi-second random delays, +randomized click positions). Pass ``pacing=wabot.NoPacing()`` for full +speed on sites you trust. +``` + +`docs/sessions.rst`: + +```rst +Persistent sessions +=================== + +The flagship feature: a browser created with a ``session`` name survives +the Python process, and any later process can pick it up by name. + +.. code-block:: python + + import wabot + + bot = wabot.browser(session="scraper1", browser="firefox") + bot.driver.get("https://example.com/login") + # ... log in, then the process exits; the browser stays open ... + + # hours later, a different process: + bot = wabot.browser(session="scraper1", browser="firefox") + print(bot.driver.current_url) # still logged in + +How it works +------------ + +A session is just ``(executor_url, session_id)`` stored as JSON in the +platform data dir. Nothing is pickled. The browser is hosted by a driver +service that outlives Python: + +* **Managed (default):** wabot spawns chromedriver/geckodriver as a + detached process and records its port and pid. +* **External:** pass ``host="http://localhost:4444"`` to use a Selenium + Grid or standalone server you run yourself. wabot then never kills the + server — only the session. + +Reattaching constructs a driver that *adopts* the saved session id +instead of creating a new session. Dead sessions (browser closed, server +gone, record older than 3 days) are pruned automatically and a fresh +browser is created instead. + +Housekeeping +------------ + +.. code-block:: python + + wabot.sessions() # ["scraper1", ...] + wabot.destroy("scraper1") # quit browser, stop managed service, forget + +Selenium Grid caveat +-------------------- + +Grid 4 kills idle sessions after **5 minutes** by default. If your +process sleeps longer between reattaches, raise it:: + + java -jar selenium-server.jar standalone --session-timeout 86400 +``` + +`docs/page-objects.rst`: + +```rst +Page objects +============ + +Model each website page as a :class:`wabot.Page` subclass with a +class-level ``elements`` map; drive them through one +:class:`wabot.Browser`. + +.. code-block:: python + + class Inventory(wabot.Page): + elements = { + "search": ("el", ("id", "search-box")), + "region": ("select", ("id", "region")), + "instock": ("checkbox", ("id", "in-stock-only")), + "rows": ("els", ("css selector", "table#items tr")), + } + +Element types +------------- + +=========== ========================================= ===================== +Type key Returned wrapper Main methods +=========== ========================================= ===================== +``el`` :class:`wabot.TextField` get_value / set_value +``select`` :class:`wabot.SelectField` set_value(value|text) +``checkbox`` :class:`wabot.CheckField` get/set_checked +``els`` list of raw WebElements (selenium API) +a class your :class:`wabot.PageObject` subclass whatever you define +=========== ========================================= ===================== + +Missing elements come back as a falsy :class:`wabot.NullField`, so guard +with ``if el:`` instead of try/except. + +Inheritance +----------- + +Element lookup walks the MRO: subclasses inherit the parent map and can +override individual keys — useful for sites with shared page chrome. + +Verification and delegation +--------------------------- + +``Browser.set_page(PageClass)`` refuses the switch if the page's +``verify()`` returns False. Once set, unknown attributes on the Browser +delegate to the current page: ``bot.log_in(...)`` calls +``bot.page.log_in(...)``. After any trapped failure the Browser refuses +further actions until ``bot.reset()``. +``` + +`docs/api.rst`: + +```rst +API reference +============= + +.. automodule:: wabot + :members: browser, sessions, destroy + +.. autoclass:: wabot.Browser + :members: + +.. autoclass:: wabot.Page + :members: + +.. automodule:: wabot.fields + :members: PageObject, TextField, SelectField, CheckField, NullField + +.. automodule:: wabot.pacing + :members: + +.. automodule:: wabot.sessions + :members: + +.. automodule:: wabot.hosts + :members: ExternalServer, ManagedService, attach, service_alive +``` + +- [ ] **Step 3: Build and verify** + +Run: `uv run sphinx-build -M html docs docs/_build -W` +Expected: `build succeeded` with zero warnings (`-W` turns warnings into errors). Open `docs/_build/html/index.html` if in doubt. + +- [ ] **Step 4: Commit** + +```bash +git add docs/conf.py docs/index.rst docs/quickstart.rst docs/sessions.rst docs/page-objects.rst docs/api.rst +git commit -m "docs: sphinx site — quickstart, sessions guide, page objects, API ref" +``` + +--- + +### Task 18: Finish — README, CLAUDE.md, lint, full verification + +**Files:** +- Modify: `README.md` (replace entirely) +- Modify: `CLAUDE.md` (update commands + architecture sections) + +- [ ] **Step 1: Rewrite README.md** + +```markdown +# WABot — Web Automator Bot + +Stateful Selenium browser automation for Python. Model pages as classes, +drive them through one facade, and — the flagship — keep the browser +alive after your process exits so another Python process can pick it up +by name. + +```python +import wabot + +bot = wabot.browser(session="scraper1", browser="firefox") +bot.driver.get("https://example.com/login") +# ... log in; process exits; browser stays open ... + +# a different process, later: +bot = wabot.browser(session="scraper1", browser="firefox") # reattaches +``` + +Human-like pacing (random delays, randomized click positions) is on by +default; pass `pacing=wabot.NoPacing()` for full speed. + +## Development + +```bash +uv sync # install everything +uv run pytest # unit tests (fast, no browsers) +uv run pytest -m integration # real headless-browser tests +uv run ruff check . && uv run ruff format --check . +uv run sphinx-build -M html docs docs/_build # docs +``` + +Docs: quickstart, persistent-sessions guide, and API reference under +`docs/` (build locally with the command above). +``` + +- [ ] **Step 2: Update CLAUDE.md** + +Replace the "What this is" third paragraph and the whole "Architecture" and "Gotchas" sections to match the new reality. Replace the sentence about no test suite with: + +```markdown +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). +``` + +In the Architecture section: `BrowserProxy` → `Browser` (`src/wabot/browser.py`), add `pacing.py` (HumanPacing/NoPacing policies), and replace the dill/pickle description with: sessions persist as JSON records `(executor_url, session_id)` in `platformdirs.user_data_dir("wabot")/sessions.json`; `hosts.py` spawns detached driver services (`ManagedService`) or connects to external ones (`ExternalServer`); `ReattachingRemote.start_session` adopts the saved session id. Delete the Gotchas bullets about `LOGGER.trace`, undeclared deps, and star exports (all fixed); keep the Gotcha that external hosts must outlive Python and add: geckodriver reports `ready:false` while its single session is active, so liveness checks ignore the `ready` flag. + +- [ ] **Step 3: Full verification** + +Run: `uv run ruff check . && uv run ruff format --check .` +Expected: clean (fix any complaints — they will be import order or line length). + +Run: `uv run pytest` +Expected: all unit tests pass. + +Run: `uv run pytest -m integration` +Expected: all integration tests pass. + +Run: `uv run sphinx-build -M html docs docs/_build -W` +Expected: build succeeded. + +Run: `uv build` +Expected: produces `dist/wabot-0.2.0.tar.gz` and a wheel — proves the uv_build backend + src layout are correct. + +- [ ] **Step 4: Commit** + +```bash +git add README.md CLAUDE.md +git commit -m "docs: rewrite README and CLAUDE.md for the modernized library" +``` + +--- + +## Execution notes + +- Tasks 1–12 are pure TDD with no browser required; Tasks 13–16 need the real Firefox/Chromium on this machine; Task 17–18 close out docs and hygiene. +- If an integration test fails, use superpowers:systematic-debugging — do not weaken assertions or mark xfail to get green. +- After Task 18, use superpowers:finishing-a-development-branch (the user pushes to Gitea manually and opens the PR). From afa47186cc980953d38160355de5a9c349972c09 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 21:23:55 -0600 Subject: [PATCH 03/35] build: replace setup.py with uv project, src layout --- pyproject.toml | 39 ++ setup.py | 44 -- src/wabot/__init__.py | 1 + tests/integration/__init__.py | 0 tests/unit/__init__.py | 0 uv.lock | 1084 +++++++++++++++++++++++++++++++++ wabot/__init__.py | 4 - wabot/api.py | 251 -------- wabot/create_browser.py | 316 ---------- wabot/fields.py | 110 ---- wabot/page.py | 537 ---------------- 11 files changed, 1124 insertions(+), 1262 deletions(-) create mode 100644 pyproject.toml delete mode 100755 setup.py create mode 100644 src/wabot/__init__.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/unit/__init__.py create mode 100644 uv.lock delete mode 100644 wabot/__init__.py delete mode 100644 wabot/api.py delete mode 100644 wabot/create_browser.py delete mode 100644 wabot/fields.py delete mode 100644 wabot/page.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fa31236 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "wabot" +version = "0.2.0" +description = "Stateful Selenium browser automation with sessions that survive the Python process" +readme = "README.md" +authors = [{ name = "Mathew Guest", email = "t3h.zavage@gmail.com" }] +requires-python = ">=3.10" +dependencies = [ + "selenium>=4.45,<5", + "platformdirs>=4", + "pillow>=10", +] + +[dependency-groups] +dev = [ + "pytest>=8", + "pytest-cov>=5", + "ruff>=0.8", + "sphinx>=7", + "sphinx-rtd-theme>=3", +] + +[build-system] +requires = ["uv_build>=0.11,<0.12"] +build-backend = "uv_build" + +[tool.pytest.ini_options] +addopts = '-m "not integration"' +markers = [ + "integration: real-browser tests (run with: uv run pytest -m integration)", +] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM"] diff --git a/setup.py b/setup.py deleted file mode 100755 index ff750a5..0000000 --- a/setup.py +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env python -# -# Usage: -# -# First, enable the python environment you want to install to, or if installing -# system-wide then ensure you're logged in with sufficient permissions -# (admin or root to install to system directories) -# -# installation: -# -# $ ./setup.py install -# -# de-installation: -# -# $ pip uninstall - - -from setuptools import setup - -__project__ = 'WaBoT' -__version__ = '0.1.0' - -setup( - name = __project__, - version = __version__, - description = '', - author = 'Mathew Guest', - author_email = 't3h.zavage@gmail.com', - - # Third-party dependencies; will be automatically installed - install_requires = ( - 'dill', - 'selenium', - ), - - # Local packages to be installed (our packages) - packages = ( - 'wabot', - ), - - # Binaries/Executables to be installed to system - scripts=() -) - diff --git a/src/wabot/__init__.py b/src/wabot/__init__.py new file mode 100644 index 0000000..cdc5338 --- /dev/null +++ b/src/wabot/__init__.py @@ -0,0 +1 @@ +"""wabot — stateful Selenium browser automation with reattachable sessions.""" diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..d29a105 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1084 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", size = 174578, upload-time = "2026-07-06T21:32:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", size = 185071, upload-time = "2026-07-06T21:32:24.671Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, + { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/97/c52dc440c390b6cfa87be9432b141a956e2d56d9b9f5fc8bd71c5f471722/coverage-7.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50913d4bf5ddafa6ca3693da5e4dd833dd1b772e0283c99ca7f7d287db67331a", size = 220539, upload-time = "2026-07-02T13:08:19.252Z" }, + { url = "https://files.pythonhosted.org/packages/3f/26/602de8c2aec7e2e3e99ebfb8e04ba65598f746275396eea5f6794ff4673f/coverage-7.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:359e141ccd33893ce3f1ad5525f8b96083003677c82182e5907d62d4ea5799fc", size = 221058, upload-time = "2026-07-02T13:08:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/fc/13/ebab0743138891c1d646d61e247ec29639afcbb6c4e1905e6a0f0c75291a/coverage-7.15.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3200b6204935f928c64b2ca1f923ab8c1acb7c9de45ec61569711b34d25cccaf", size = 247797, upload-time = "2026-07-02T13:08:22.474Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b7/b6ffb9e042aa48dc4144a8a65529affaec8dca0685309353614a2a7386ad/coverage-7.15.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be616bf61346883b2cfdc5178669647e03531d81ab761a7e378558b7e8bcb628", size = 249626, upload-time = "2026-07-02T13:08:23.803Z" }, + { url = "https://files.pythonhosted.org/packages/9c/06/243ff05b652333d8e3d060c11223efc2723b19cacf6605e433fa686ab5d4/coverage-7.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7bafc3fe1059463a8fdd97ca79972d6e2bf819d775c7d54991b5b1971201d6", size = 251493, upload-time = "2026-07-02T13:08:25.397Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2b/867faa17030a806114dae388b32a3fa929d8cd4bf39226fbc11f6e6bb705/coverage-7.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b713aa7fcf325a01d4184d848acb46fd84f78fdb0978470c636b23a06a753d91", size = 253406, upload-time = "2026-07-02T13:08:26.842Z" }, + { url = "https://files.pythonhosted.org/packages/94/c0/d789ce18f6605afc4895db75723424be2ef494282f77f61d8e5832923183/coverage-7.15.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38e6fba2d56652fdfaf0231f8f78aeb805234a912de25dc291ee5cce5b8faa4", size = 248512, upload-time = "2026-07-02T13:08:28.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/b2673c30739f4a2e06649a0a38ad8b093c4d865462dc7bab0e9524a2c3b1/coverage-7.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:884499f42e382675be80770391983b90e0c0c774d87dbeeebf5f991cf6612b20", size = 249532, upload-time = "2026-07-02T13:08:29.731Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/acd79e9a41beabee92b623afe4f30b549916f48566271475f2907e752828/coverage-7.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:840481b12e083dbcbafab14794a8781a958edf327c8d3d70b4eee42f9b8253aa", size = 247537, upload-time = "2026-07-02T13:08:31.173Z" }, + { url = "https://files.pythonhosted.org/packages/12/d4/2d301c4d1b3238d7c88b70ab9d13fd53ed9505662a7ff1b46ba1e2e4e3c3/coverage-7.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:276646e9481703d09f854f3b2f018f24e19fd7049ae670a92570043eb97203b1", size = 251348, upload-time = "2026-07-02T13:08:32.63Z" }, + { url = "https://files.pythonhosted.org/packages/35/bb/c67708b2bc00f32e12805ec23d5fa677a0a51652f449341a89f9d6b1b715/coverage-7.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4de4b4d3f5545aa6c60dc4efd9c63b5b5dcc3bf00fe83146b2bdfffb8f6613bd", size = 247806, upload-time = "2026-07-02T13:08:33.931Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6c/57c4f653c47a6e917748f8938e389e72fbcae44e3643cd906664f0477a13/coverage-7.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5c504097b2a89b1e85bc6070d920df77daec701337e3aeef2c17775a5dd0ca90", size = 248410, upload-time = "2026-07-02T13:08:35.189Z" }, + { url = "https://files.pythonhosted.org/packages/6c/94/bb083041aef828903668f134273f319f2bd49224962875359c52faa5497f/coverage-7.15.0-cp310-cp310-win32.whl", hash = "sha256:f6e80ed91f98316e86b9c137206b04b2bcfbffccbdff49bd2eb09dddb1cf14e0", size = 222588, upload-time = "2026-07-02T13:08:36.486Z" }, + { url = "https://files.pythonhosted.org/packages/ef/94/a09d8ee618956f626741b0734854bac4425a00e10c0565f5abca64e7e751/coverage-7.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:b3b3e22030f3f6f5e01a5ce69936552a5c0f6992b7698777377b99041961031f", size = 223214, upload-time = "2026-07-02T13:08:37.885Z" }, + { url = "https://files.pythonhosted.org/packages/ae/23/82e910835ef4b8391047025e1d53aa48d66029f444eb8b25373c849bf503/coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a", size = 220662, upload-time = "2026-07-02T13:08:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0d/c7b213dde2f1579de5231062b386d8413f79c11667eb58c39319b25991da/coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118", size = 221168, upload-time = "2026-07-02T13:08:40.471Z" }, + { url = "https://files.pythonhosted.org/packages/33/77/d000aeedfac085088337b3c7becdad328474b1f8a9e4c9368a0c99605d68/coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2", size = 251587, upload-time = "2026-07-02T13:08:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e0/86787c56b9df17afd370d5e293515dd4d9a107a561d13054873eefad8ecc/coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8", size = 253497, upload-time = "2026-07-02T13:08:43.387Z" }, + { url = "https://files.pythonhosted.org/packages/3f/02/181bc917359299c07dead6270f94e411151c8b60cec905c33499da69afe6/coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5", size = 255607, upload-time = "2026-07-02T13:08:44.897Z" }, + { url = "https://files.pythonhosted.org/packages/b9/35/ca5e7427699913da6788c4f910e73ab16c5f4b59ec5d3a999dce2a45112f/coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723", size = 257563, upload-time = "2026-07-02T13:08:46.334Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4d/b8220bacc2fc3c4e9078e27c32e99fb411479a4718a72bdd00036a9891c8/coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735", size = 251726, upload-time = "2026-07-02T13:08:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e4/2e145da1991d72189b9c3cf7eca05c716ee7080d099aaea6757cfc7df008/coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9", size = 253301, upload-time = "2026-07-02T13:08:49.5Z" }, + { url = "https://files.pythonhosted.org/packages/72/28/d2c841d698bf762e481f08bd4839d370246b6d9b61dab085a7b20b201a08/coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035", size = 251361, upload-time = "2026-07-02T13:08:51.304Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ed/55d9ffde994fba3897c0c783f77a7d053b0c18787f6892ed5b0aed73f469/coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671", size = 255129, upload-time = "2026-07-02T13:08:52.661Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c0/ecbf33b8c460ea2718aeb813e2df8140d0370e5f67261c31524ceb0a2a8d/coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695", size = 251081, upload-time = "2026-07-02T13:08:54.188Z" }, + { url = "https://files.pythonhosted.org/packages/a9/de/fb87b4261f54448dd2b9504ef19a58be42cef0d9520595fbfe1219b15234/coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540", size = 251988, upload-time = "2026-07-02T13:08:55.726Z" }, + { url = "https://files.pythonhosted.org/packages/df/27/3494d5f291b9a4cb868f73c11221a8bd2d5bd761a8f9acea61ff57128dd1/coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4", size = 222754, upload-time = "2026-07-02T13:08:57.091Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ee/cd4847ebc9be6a9c0123d763645a6f1f3be6b8c58c962706368b79cbac07/coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6", size = 223225, upload-time = "2026-07-02T13:08:58.594Z" }, + { url = "https://files.pythonhosted.org/packages/57/37/5011581aa7f2be498b97dcc7c9902192442a42f4f9a748aeadb3d6506b42/coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640", size = 222774, upload-time = "2026-07-02T13:09:00.074Z" }, + { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, + { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, + { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, + { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, + { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, + { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" }, + { url = "https://files.pythonhosted.org/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" }, + { url = "https://files.pythonhosted.org/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" }, + { url = "https://files.pythonhosted.org/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" }, + { url = "https://files.pythonhosted.org/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" }, + { url = "https://files.pythonhosted.org/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" }, + { url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" }, + { url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" }, + { url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" }, + { url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" }, + { url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" }, + { url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" }, + { url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" }, + { url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" }, + { url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" }, + { url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" }, + { url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "selenium" +version = "4.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "trio" }, + { name = "trio-websocket" }, + { name = "typing-extensions" }, + { name = "urllib3", extra = ["socks"] }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/48/486aa67320f27452e9f551b8608f1a59ce7091c8fe7ebc9f4eba274775d4/selenium-4.45.0.tar.gz", hash = "sha256:563f0c4102f112df1cda30d46ce6d177b2e4a7a3d4b0756902d5dc84d3a8a365", size = 1005503, upload-time = "2026-06-16T04:43:57.915Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/8a/6ff6beb9c7c6cc642f628df9328a8b6637f86602eff8d28e70b5d4e8bca7/selenium-4.45.0-py3-none-any.whl", hash = "sha256:1fd9d0dc08192b2f8100e264ed720f83b05d2dd3a7feff673df04e0c7580df4b", size = 9536616, upload-time = "2026-06-16T04:43:55.968Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89", size = 7655617, upload-time = "2026-01-12T16:03:28.101Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "trio" +version = "0.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109, upload-time = "2026-02-14T18:40:55.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, +] + +[[package]] +name = "trio-websocket" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "outcome" }, + { name = "trio" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + +[[package]] +name = "wabot" +version = "0.2.0" +source = { editable = "." } +dependencies = [ + { name = "pillow" }, + { name = "platformdirs" }, + { name = "selenium" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-rtd-theme" }, +] + +[package.metadata] +requires-dist = [ + { name = "pillow", specifier = ">=10" }, + { name = "platformdirs", specifier = ">=4" }, + { name = "selenium", specifier = ">=4.45,<5" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8" }, + { name = "pytest-cov", specifier = ">=5" }, + { name = "ruff", specifier = ">=0.8" }, + { name = "sphinx", specifier = ">=7" }, + { name = "sphinx-rtd-theme", specifier = ">=3" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] diff --git a/wabot/__init__.py b/wabot/__init__.py deleted file mode 100644 index d643e46..0000000 --- a/wabot/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .api import * -from .page import * -from .fields import * - diff --git a/wabot/api.py b/wabot/api.py deleted file mode 100644 index c0b0201..0000000 --- a/wabot/api.py +++ /dev/null @@ -1,251 +0,0 @@ -from .create_browser import * - -import appdirs -import logging -# import pickle -import dill as pickle -import selenium.common.exceptions -import selenium.webdriver -import sys -import time -import traceback -import os - -USER_AGENT = "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0; Trident/5.0)" -REFUSE_AFTER_EXCEPTION = True -EXECUTOR_PORT = 4444 -REMOTE_EXECUTOR = 'http://127.0.0.1:%s/wd/hub' - -# PICKLE_FILENAME = '/tmp/nhsnwebdriverdump' -# PICKLE_FILENAME = os.path.join( -# appdirs.user_data_dir('wabot'), -# 'saved_browser_instances.pickle' -# ) - -LOGGER = logging.getLogger('wabot') - -DEFAULT_WEBDRIVER_TYPE = 'firefox1' - -class BrowserProxy: - def __init__( - self, - session_name='webdriver', - pickle_filename=None, - phantom=False, - webdriver_type=None # remote_chromium2 - ): - """ - BrowserProxy wraps a selenium webdriver instance and provides utility - functions for automation webpages. - """ - LOGGER.info('requesting selenium browser instance (%s): instance_name = %s', webdriver_type, session_name) - - # if pickle_filename is None: - # pickle_filename = PICKLE_FILENAME - - # self._pickle_filename = pickle_filename - - if webdriver_type is None: - webdriver_type = 'firefox1' - - assert webdriver_type in ( - 'firefox1', - 'firefox2', - 'chromium2', - 'remote_chromium', - 'remote_chrome', - 'phantomjs', - 'remote_firefox' - ), 'webdriver_type must be firefox1, firefox2, chromium2, remote_chromium2, or phantomjs' - - - - - try: - self.driver_type = webdriver_type - if phantom: - pass - # driver_type = "phantomjs" - self.driver = self.get_driver(webdriver_type, session_name) - if not self.driver: - LOGGER.error('failed to get selenium webdriver') - self.good = False - return - except Exception as ex: - print('caught exception at BrowserProxy().__init__') - print(type(ex), ex) - raise - - # self.page = nhsn_lo.pages.Login(self) - # self.good = True - - def get_page(self): - return type(self.page) - - def set_page(self, page): - LOGGER.trace('switching page to %s' % page) - newpage = page(self) - if hasattr(newpage, 'verify'): - rc = newpage.verify() - if not rc: - LOGGER.error('failed to verify page: %s' % page) - # input('failure') - return False - else: - LOGGER.trace('verified page: %s' % page) - - self.page = newpage - return True - - def switch_after(self, fn=None, page=None): - if not fn: - return lambda fn: self.switch_after(fn, page = page) - - def wrapper(*args, **kwargs): - self.set_page(page) - return fn(*args, **kwargs) - - return wrapper - - def __getattr__(self, name): - """ - Access method on current page. - - When object.method is referenced, python invokes - object.__getattribute__ which checks the instance - for a matching property, If none is found, python - invokes object.__getattr__, which is overridden here. - If the attribute is not found in the main class, this - will search the page object for the method. This - allows invoking page methods through the proxy class. - - nhsn.foo() invokes nhsn.page.foo() - """ - # todo(mg) would this be a good spot to inject page verification? - # idea: maybe we could decorate method with error checking and - # error collection? method can return value, rc and this can - # queue actions and error check them as it goes? At the end, - # the user would be able to pull out the errors and check them - if REFUSE_AFTER_EXCEPTION and not self.good: - LOGGER.warn("broken state - refusing to invoke page action: %s" % (name)) - return - - try: - method = getattr(self.page, name) - return method - except AttributeError as ex: - raise - - def __getitem__(self, key): - """ - Access element on the current page. - """ - return self.page[key] - - def perform(self, meth, *args, **kwargs): - """ - Calls self.page.method and catches exceptions. - """ - # input(' at perform') - if REFUSE_AFTER_EXCEPTION and not self.good: - LOGGER.warn("broken state - refusing to invoke page action: %s" % (meth)) - return - - try: - x = getattr(self.page, meth) - except AttributeError as ex: - LOGGER.error("Failed to invoke page action. "\ - "page = %s, method = %s" %\ - (self.page.__class__, meth)) - return None - - # Hooks: - # x = self.page.detect_logged_out() - # y = self.page.detected_logged_in() - # print('logged out/in: ', x, y) - - LOGGER.trace("invoking %s.%s" % (self.page.__class__.__name__, meth)) - try: - return x(*args, **kwargs) - except selenium.common.exceptions.NoSuchWindowException: - self.good = False - except Exception as ex: - print("caught exception: %s" % (type(ex))) - exc_type, exc_value, exc_traceback = sys.exc_info() - lines = traceback.format_exception(exc_type, exc_value, - exc_traceback) - for line in lines: - print(line, end = "") - - self.good = False - - def reset_good_status(self): - self.good = True - - def get_driver(self, browser, session_name='webdriver'): - """ - Returns selenium objects of different types - - The combinations of selenium{1,2} {chrome,firefox} are supported. - In the case of selenium 1 (selenium server), the host is a constant - defined at the top of this file. - - Args: - browser: The selenium webdriver requested. - If a chromium2 webdriver is requested, selenium - will try to use chromium as the browser. If - firefox2 is requested, selenium will try to use - firefox. Chromium is very fast but it is designed - to be fast for an interactive user which means it - is fast at the cost of processing power and - ram. Firefox is about as fast but uses less - resources. - - Available browser drivers are: - chromium2, chromium1, firefox2, firefox1 - - The associated webdriver has to be installed - and runnable on the system. - Returns: - The selenium webdriver handle. - """ - LOGGER.debug('requesting selenium browser instance: type = %s' % (browser)) - - driver = None - browser_factory = CreateBrowser() - if browser == 'chromium2': # Selenium 2 - Chrome - driver = self._create_driver_chromium() - - elif ( - browser == 'remote_chromium' - or browser == 'remote_chrome' - ): - driver = browser_factory._create_driver_remote_chromium(session_name) - - elif browser == 'remote_firefox': # Selenium 1 - Firefox - driver = browser_factory._create_driver_remote_firefox(session_name) - - - # TODO(MG) need to redo/rename below. selenium2/ not remote, uses native browser - # webdrviers instead of javascript - elif browser == 'chromium1': # Selenium 1 - Chrome without working user agent switch - driver = self._create_driver_chromium1() - - elif browser == 'firefox2': # Selenium 2 - Firefox - driver = self._create_driver_firefox2() - - - - elif browser == 'phantomjs': - driver = self._create_driver_phantomjs() - else: - LOGGER.error( - 'an attempt was made to request an '\ - 'unsupported (by this product) selenium '\ - 'webdriver; refusing. requested = %s'\ - % (browser) - ) - - driver.implicitly_wait(10) - return driver - diff --git a/wabot/create_browser.py b/wabot/create_browser.py deleted file mode 100644 index 856f5c7..0000000 --- a/wabot/create_browser.py +++ /dev/null @@ -1,316 +0,0 @@ - -import appdirs -import datetime -import logging -import os -import selenium -import pickle - - -LOGGER = logging.getLogger('wabot') - -USER_AGENT = "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0; Trident/5.0)" -REFUSE_AFTER_EXCEPTION = True -EXECUTOR_PORT = 4444 -REMOTE_EXECUTOR = 'http://127.0.0.1:%s/wd/hub' - - -class PickledBrowserReference: - """ - Structure for saving a webdriver instance. Also includes the timestamp - to help invalidate old references. - """ - def __init__(self): - self.browser_ref = None - self.timestamp = None - -# Factory Creator - instantiate Selenium1 and Selenium2 webdriver instances. -class CreateBrowser: - """ - Creates and instantiates selenium webbrowser instances. - - Two strategies: remote via selenium server or local. - - The advantage of using remote is the browser instance can stay - open after the process closes. This allows you to disconnect - and re-connect to the same browser instance with different - processes. - """ - def __init__(self, pickle_filename=None): - self._pickle_filename = None - if pickle_filename is not None: - self.pickle_filename = pickle_filename - self._has_cleaned = False - - @property - def pickle_filename(self): - """ - Reference to the user-data-dir pickle target filename, for - saving browser references. - """ - if self._pickle_filename is None: - self._pickle_filename = os.path.join( - appdirs.user_data_dir('wabot'), - 'saved_browser_instances.pickle' - ) - return self._pickle_filename - else: - return self._pickle_filename - - @pickle_filename.setter - def pickle_filename(self, value): - self._pickle_filename = value - - def _clean_old_pickles(self): - if self._has_cleaned is True: - LOGGER.debug('not cleaning pickles twice') - return - - p = self.pickle_filename - try: - fp = open(p, 'rb') - pickles = pickle.load(fp) - if not pickles: - raise Exception - - - LOGGER.info('cleaning any old pickle refs') - - - for pickle_ref in pickles: - driver_ref = pickles[pickle_ref] - ts = driver_ref.timestamp - - if datetime.datetime.now() - driver_ref.timestamp >= datetime.timedelta(days=3): - LOGGER.info('deleting old pickled driver ref: %s at %s', pickle_ref, ts) - pickles.pop(final_name) - - self._has_cleaned = True - - except (FileNotFoundError, IOError) as ex: - LOGGER.error('unable to load pickles: no pickled drivers found') - except Exception as ex: - print(ex) - pass - - def _load_from_pickle(self, final_name): - p = self.pickle_filename - - - # Definitely no browser instance already, we must instantiate - if not os.path.exists(p): - LOGGER.debug('no pickled file for saved browser instances (nothing saved yet)') - return - - # There MAY be an open browser or an invalidated reference to a once-open browser - if os.path.exists(p): - LOGGER.debug('found pickled file for saved browser instances: %s', p) - # First, see if existing session_name browser instance exists - fp = None - - self._clean_old_pickles() - try: - fp = open(p, 'rb') - # drivers = pickle.load(fp) - pickles = pickle.load(fp) - if not pickles: - raise Exception - - for idx, saved_instance in enumerate(pickles): - LOGGER.debug('found saved browser instances (%s): %s', idx, saved_instance) - - driver_ref = pickles.get(final_name) - if not driver_ref: - raise Exception - - # if datetime.datetime.now() - driver_ref.timestamp >= datetime.timedelta(days=3): - # pickles.pop(final_name) - - driver = driver_ref.browser_ref - LOGGER.debug('connected to pickled webdriver instance: %s', final_name) - url = driver.current_url # throw error if driver isn't reliable anymore - LOGGER.info('webdriver instance is ready') - # self.driver = driver - return driver - except (FileNotFoundError, IOError) as ex: - LOGGER.error('unable to connect to existing webdriver: no pickled drivers found') - except Exception as ex: - print(ex) - self.driver = None - - def _save_to_pickle(self, final_name, driver_ref): - p = self.pickle_filename - pickles = {} - - # Definitely no browser instance already, we must instantiate - if not os.path.exists(p): - LOGGER.debug('no pickled file for saved browser instances (nothing saved yet)') - - # Create usr-app-dir if doesn't exist - dirname = os.path.dirname(p) - if not os.path.isdir(dirname): - os.mkdir(dirname) - - # There MAY be an open browser or an invalidated reference to a once-open browser - if os.path.exists(p): - LOGGER.debug('found existing pickle file for saved browser instances: %s', p) - # First, see if existing session_name browser instance exists - - - self._clean_old_pickles() - - fp = open(p, 'rb') - pickles = pickle.load(fp) - if not pickles: - raise Exception - LOGGER.debug('found saved browser instances while saving: %s', list(pickles.keys())) - fp.close() - - - # Save to pickle - creating file if necessary - pkl = PickledBrowserReference() - pkl.browser_ref = driver_ref - pkl.timestamp = datetime.datetime.now() - pickles[final_name] = pkl - - - - import pprint; - print('pickles:') - pprint.pprint(pickles) - - # pickle file must be created - fp = open(p, 'wb') - # drivers[final_name] = driver - LOGGER.info('saving browser instance to pickle: %s', final_name) - pickle.dump(pickles, fp) - fp.close() - - def _create_driver_remote_chromium(self, session_name): - """ - Creates and returns Selenium1 chromium instance. - """ - p = self.pickle_filename - # e.g.: remote-chromium-webdriver - final_name = '{}-{}'.format('remote-chromium', session_name) - driver = None - - driver = self._load_from_pickle(final_name) - if driver is not None: - LOGGER.info('webdriver instance is ready (from pickle)') - return driver - - # At this point, need to instantiate a new browser instance - sel_host = REMOTE_EXECUTOR % (EXECUTOR_PORT) - LOGGER.info('instantianting new browser instance (remote_chromium)') - LOGGER.info('remote selenium: %s', sel_host) - - opt = selenium.webdriver.chrome.options.Options() - opt.add_argument("--user-agent=" + USER_AGENT) - opt.add_argument("--kiosk-printing") - opt.add_argument("--focus-existing-tab-on-open=false") - driver = selenium.webdriver.Remote( - command_executor=sel_host, - desired_capabilities = opt.to_capabilities() - ) - - self._save_to_pickle(final_name, driver) - - # self.driver = driver - return driver - - - # Pickle impl. is duped here - - def _create_driver_remote_firefox(self, session_name): - """ - Creates and returns Selenium1 firefox instance. - """ - final_name = '{}-{}'.format('remote-firefox', session_name) - driver = None - - driver = self._load_from_pickle(final_name) - if driver is not None: - LOGGER.info('webdriver instance is ready (from pickle)') - return driver - - # At this point, need to instantiate a new browser instance - sel_host = REMOTE_EXECUTOR % (EXECUTOR_PORT) - LOGGER.info('instantianting new browser instance (remote_firefox)') - LOGGER.info('remote selenium: %s', sel_host) - - profile = selenium.webdriver.FirefoxProfile() - profile.set_preference("general.useragent.override", USER_AGENT) - driver = selenium.webdriver.Remote( - # SELENIUM1_SERVER_PATH, - sel_host, - selenium.webdriver.DesiredCapabilities.FIREFOX.copy(), - browser_profile = profile - ) - - self._save_to_pickle(final_name, driver) - return driver - - - - def _create_driver_chromium2(self): - opt = selenium.webdriver.chrome.options.Options() - opt.add_argument("--user-agent=" + USER_AGENT) - opt.add_argument("--kiosk-printing") - driver = selenium.webdriver.Chrome(chrome_options = opt) - self.driver = driver - return driver - - def _create_driver_chromium1(self): - # Selenium 1 - Chrome without working user agent switch - # These two methods of creation ChromeOptions are equivalent objects - options = selenium.webdriver.ChromeOptions() - options.add_argument("--user-agent=" + USER_AGENT) - driver = selenium.webdriver.Remote(desired_capabilities = options.to_capabilities()) - driver = selenium.webdriver.Remote(SELENIUM1_SERVER_PATH, - selenium.webdriver.DesiredCapabilities.CHROME.copy()) - return driver - - def _create_driver_firefox2(self): - # tmp = selenium.webdriver.FirefoxProfile() - # tmp = None - profile = None - # filename = "/tmp/firefox_profile" - # try: - # fp = open(filename, "rb") - # profile = pickle.load(fp) - # except: - # pass - - if not profile: - profile = selenium.webdriver.FirefoxProfile(profile_directory = "/home/mathew/firefox_prof") - profile.set_preference("general.useragent.override", USER_AGENT ) - profile.set_preference("browser.helperApps.neverAsk.saveToDisk", "text/csv"); - profile.set_preference("network.http.redirection-limit", "0" ) - # profile.set_preference("javascript.enabled", False ) - # profile.set_preference("print.always_print_silent", True) - profile.set_preference("print.print_to_file", True) - profile.set_preference("print.print_to_filename", "/tmp/print.pdf") - profile.update_preferences() - profile.set_preference("network.http.redirection-limit", "0" ) - # with open("/tmp/firefox_profile", "wb") as fp: - # pickle.dump(profile, fp, pickle.HIGHEST_PROTOCOL) - - # driver = selenium.webdriver.Firefox() - driver = selenium.webdriver.Firefox(profile) - return driver - - - - def _create_driver_phantomjs(self): - # Note(MG): Selenium support for PhantomJS has been deprecated, please use headless - # driver = selenium.webdriver.PhantomJS() - # return driver - opt = selenium.webdriver.chrome.options.Options() - opt.add_argument("--user-agent=" + USER_AGENT) - opt.add_argument("--kiosk-printing") - opt.add_argument("--headless") - driver = selenium.webdriver.Chrome(chrome_options = opt) - driver.set_window_size(838, 907) - self.driver = driver - return driver diff --git a/wabot/fields.py b/wabot/fields.py deleted file mode 100644 index 54f0a7a..0000000 --- a/wabot/fields.py +++ /dev/null @@ -1,110 +0,0 @@ -import logging -import random -import selenium -import time - -LOGGER = logging.getLogger('wabot') - -class PageObject: - """ - Wrapper around page element that provides an object orientated interface - to elements. Can be sublcassed to integrate more complicated instruction. - """ - def __init__(self, page, accessors=None, name=None): - self.page = page - self.driver = page.driver - self.accessors = accessors - self.name = name - # self.el = None - - def __getattr__(self, name): - # if not self.el: - # raise AttributeError - try: - return getattr(self.el, name) - except AttributeError as ex: - raise - - def selenium_element(self, accessors=None): - """ - Creates and returns a selenium webelement for - interfacing with the page. - """ - if not accessors: - accessors = self.accessors - accessor = accessors - - by = accessor[0] - value = accessor[1] - el = self.page.driver.find_element(by=by, value=value) - return el - - def click(self): - return self.page.click(self.el) - - def click_and_go(self): - time.sleep(random.uniform(3,6)) - return self.page.click_and_go(self.el) - - # def get_value(self): - # def set_value(self, value): - # def custom, e.g. zoom(self, x, y, dx, dy) - -class TextField(PageObject): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - el = self.selenium_element(kwargs['accessors']) - self.el = el - - def get_value(self): - return self.page.get_el_value(self.el) - - def set_value(self, value): - LOGGER.info('[%s]->set_text (%s)' % (self.name, value)) - time.sleep(random.uniform(3,5)) - return self.page.set_el_value(self.el, value) - -class SelectField(PageObject): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - el = self.selenium_element(kwargs['accessors']) - dropdown = selenium.webdriver.support.ui.Select(el) - self.el = el - self.dropdown = dropdown - - def get_value(self): - return self.page.get_select_value(self.dropdown) - - def set_value(self, value): - LOGGER.info('[%s]->set_select (%s)' % (self.name, value)) - time.sleep(random.uniform(6,11)) - return self.page.set_select_value(self.dropdown, value) - - def select_by_index(self, idx): - return self.dropdown.select_by_index(idx) - - -class CheckField(PageObject): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - el = self.selenium_element(kwargs['accessors']) - self.el = el - - def get_checked(self, ignore=False): - return self.page.get_checkbox_value(self.el, ignore) - - def set_checked(self, checked): - LOGGER.info('[%s]->set_checked (%s)' % (self.name, checked)) - time.sleep(random.uniform(2,3)) - return self.page.set_checkbox(self.el, checked) - -class NullField(PageObject): - def __init__(self, el, *args, **kwargs): - self.el = el - - def __getattr__(self, attr): - raise Exception - - def __bool__(self): - return False - diff --git a/wabot/page.py b/wabot/page.py deleted file mode 100644 index dae17c0..0000000 --- a/wabot/page.py +++ /dev/null @@ -1,537 +0,0 @@ -import enum -import random -import selenium.webdriver -import time -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.support import expected_conditions as EC -import selenium.common.exceptions -from .fields import * - -import selenium.webdriver.support.ui -from selenium.webdriver.common.by import By -from PIL import Image -import os -import inspect - -import logging - -LOGGER = logging.getLogger('wabot') - -ENABLE_GOTO_CLINIC_SELECT_OPTIMIZATION = True -ALERT_TIMEOUT = 3 - -class OBJ_T(enum.Enum): - ELEMENT = 1 - ELEMENT_ARRAY = 2 - SELECT = 3 - CUSTOM = 4 - -class RC(enum.Enum): - FAILURE = 0 - SUCCESS = 1 - EVENT_WITHIN_21_DAYS = 2 - DUPLICATE_EVENT = 3 - NO_FACILITY = 4 - MISSING_REPORTING_PLAN = 5 - -class Page: - """ - Provides ancillary utility methods such as finding - elements, interacting with forms, and taking screenshots. - """ - def __init__(self, parent): - self.parent = parent - self.driver = parent.driver - LOGGER.info("Loaded Page() '%s'", self.__class__.__name__) - - def find_element_locators(self, key): - """ - Returns the generator data for an element, i.e. what - class to use and the arguments to construct it with. - This is found/stored in the static class definition, - page.elements = {key: (accessors)} - """ - class_hierarchy = inspect.getmro(type(self)) - locators = None - for cls in class_hierarchy: - if not hasattr(cls, 'elements'): # no elements defined here... - continue - locators = cls.elements.get(key) - if not locators: # our element isn't defined here - continue - return locators # return first found match - - def get_proxy(self, key): - locators = self.find_element_locators(key) - if not locators: - LOGGER.warn('element not found: %s' % (key)) - return NullField(None, page=self) - - obj_type = locators[0] - accessors = locators[1] - - # if not el: - # LOGGER.warn('failed to make proxy object: %s' % key) - # return NullField(None, page=self) - - if obj_type == 'el': - obj = TextField(page=self, accessors=accessors, name=key) - return obj - elif obj_type == 'select': - obj = SelectField(page=self, accessors=accessors, name=key) - return obj - elif obj_type == 'checkbox': - obj = CheckField(page=self, accessors=accessors, name=key) - return obj - elif obj_type == 'els': - el = self.find_element(key) - return self.find_element(key) - elif isinstance(obj_type, type): - # by = accessors[0] - # value = accessors[1] - # el = self.driver.find_element(by=by, value=value) - obj = obj_type(idelement=accessors[1], page=self, accessors=accessors, - name=key) - return obj - else: - LOGGER.error('failed to create page element type: %s' % (obj_type)) - - LOGGER.error('requested unknown page element: %s' % key) - return - - def __getitem__(self, key): - # input('__getitem__ %s' % key) - # return self.find_element(key) # DEPRECATED - return self.get_proxy(key) - - def find_element(self, key): - """ - Accesses element on page - """ - locators = self.find_element_locators(key) - if not locators: - LOGGER.error('failed to find page element: %s' % (key)) - return False - - try: - it = iter(locators) - type_ = next(it) - - if type_ == 'custom': - cls = next(it) - locators = [] - for locator in it: - locators.append(locator) - obj = cls(self, OBJ_T.CUSTOM, locators) - # print(obj) - return obj - - - for locator in it: - # print('locator =', locator) - if type_ == 'el' or type_ == 'checkbox': - try: - by = locator[0] - value = locator[1] - el = self.driver.find_element(by=by, value=value) - LOGGER.trace('found single element (%s) by %s = %s', key, by, value) - return el - except Exception as ex: - LOGGER.warn('failed to find single element (%s) by %s = %s', key, by, value) - continue - elif type_ == 'els': - try: - by = locator[0] - value = locator[1] - els = self.driver.find_elements(by=by, value=value) - LOGGER.trace('found elements (%s) by %s = %s', key, by, value) - return els - except Exception as ex: - LOGGER.warn('failed to find any elements (%s) by %s = %s', key, by, value) - continue - elif type_ == 'select': - try: - by = locator[0] - value = locator[1] - el = self.driver.find_element(by=by, value=value) - LOGGER.trace('found dropdown element (%s) by %s = %s', key, by, value) - dropdown = selenium.webdriver.support.ui.Select(el) - return dropdown - except Exception as ex: - LOGGER.warn('failed to find dropdown (%s) by %s = %s', key, by, value) - continue - - else: - LOGGER.error('unable to find element (%s): unknown type = %s' % (key, type_)) - return - except Exception as ex: - print(ex) - - def verify(self): - # The page proxy api calls PageObject.verify() if it exists, which - # should call super().verify(). It is necessary for every class in - # the hierarchy to have a callable verify(). Every class in the - # hierarchy should implement verify method or subclass from here. - return True - - def click_and_go(self, el): - rc = self.click(el) - if not rc: - return rc - self.accept_alert() - LOGGER.debug('waiting for page to load...') - rc = self._wait_for_element_to_go_stale(el) - if not rc: - LOGGER.error('failed: timed out waiting for page load') - return False - return rc - - def _wait_for_element_to_go_stale(self, el): - try: - wait = selenium.webdriver.support.ui.WebDriverWait(self.driver, 10) - wait.until(lambda driver: self.is_element_stale(el)) - return True - except selenium.common.exceptions.TimeoutException as ex: - LOGGER.error('failed: timed out waiting for page load') - return False - - def click(self, el): - """ - Clicks element at a random coordinate based on the size of the - element. - - By default, selenium clicks elements at pos(0, 0). NHSN records - where things are clicked at. - """ - if not el: - LOGGER.warn("refusing to click null element") - return False - - try: - size = el.size - except selenium.common.exceptions.StaleElementReferenceException as ex: - LOGGER.error('failed to click element: stale reference') - return False - - # Use a guassian distribution to click more often towards the center - width = size["width"] - if width > 4: - x = random.gauss((width/2), (width/7)) - if x < 0: x = 1 - elif x > width: x = width - 1 - else: - i = 0 - while i < 10: - try: - if el.is_displayed() and el.is_enabled(): - el.click() - return True - except selenium.common.exceptions.ElementNotVisibleException as ex: - LOGGER.error('failed to click element: not visible') - return False - except selenium.common.exceptions.StaleElementReferenceException as ex: - LOGGER.error('failed to click element: stale reference') - return False - time.sleep(.2) - i = i+1 - return False - - height = size["height"] - if height > 4: - y = random.gauss((height/2), (height/7)) - if y < 0: y = 1 - elif y > height: y = height -1 - else: - el.click() - return - - LOGGER.debug( "clicking %s (dim: x = %s, y = %s) at %d, %d" % - (self.get_el_identifier(el), size["width"], size["height"], x,y) ) - - i = 0 - n = 20 - while i < n: - if el.is_displayed() and el.is_enabled(): - break - if i == n: - raise Exception("unable to click element") - time.sleep(.5) - i = i + 1 - - # return el.click() - - x = int(x) - y = int(y) - # print('size is', width, height) - # print('clicking through selenium actions at', x, y) - actions = selenium.webdriver.ActionChains(self.driver) - actions.move_to_element_with_offset(el, x, y) - actions.click() - try: - actions.perform() - except Exception as ex: # type is selenium timeout... not sure - print(ex) - LOGGER.error("%s" % (ex)) - return False - return True - - def save_screenshot(self, filename): - LOGGER.info("saving page screenshot: %s" % (filename)) - - - # Chromium2 screenshot only captures viewable area, - # selenium is waiting on WebDriver which is waiting - # on chromium. Doesn't look like it will be fixed soon. - # more info: - # https://code.google.com/p/chromedriver/issues/detail?id=294 - # - # For now, the workaround is stitch it together for chromium. - # Firefox2 works fine but has it's own problems, hence the - # chromium stitch. - if self.parent.driver_type == "chromium2": - self.chrome_take_full_page_screenshot(filename) - else: - self.driver.get_screenshot_as_file(filename) - - - def chrome_take_full_page_screenshot(self, file): - self.driver.maximize_window() - # Global.scroll_to_zero() - time.sleep(0.2) - - # Log.Debug("Starting chrome full page screenshot workaround ...") - - total_width = self.driver.execute_script("return document.body.offsetWidth") - total_height = self.driver.execute_script("return document.body.parentNode.scrollHeight") - - viewport_width = self.driver.execute_script("return document.body.clientWidth") - viewport_height = self.driver.execute_script("return window.innerHeight") - - # Log.Debug("Total: ({0}, {1}), Viewport: ({2},{3})".format(total_width, total_height,viewport_width,viewport_height)) - - rectangles = [] - - i = 0 - while i < total_height: - ii = 0 - top_height = i + viewport_height - - if top_height > total_height: - top_height = total_height - - while ii < total_width: - top_width = ii + viewport_width - - if top_width > total_width: - top_width = total_width - - # Log.Debug("Appending rectangle ({0},{1},{2},{3})".format(ii,i,top_width,top_height)) - rectangles.append((ii,i,top_width,top_height)) - - ii = ii + viewport_width - - i = i + viewport_height - - - stitched_image = Image.new('RGB', (total_width, total_height)) - previous = None - part = 0 - for rectangle in rectangles: - if not previous is None: - self.driver.execute_script("window.scrollTo({0}, {1})".format(rectangle[0], rectangle[1])) - # Log.Debug("Scrolled To ({0},{1})".format(rectangle[0], rectangle[1])) - time.sleep(0.2) - - tmp_path = "/tmp/" - file_name = "{0}scroll_{1}_part_{2}.png".format(tmp_path, 1, part) - # file_name = "/tmp/screen.png" - # Log.Debug("Capturing {0} ...".format(file_name)) - - self.driver.get_screenshot_as_file(file_name) - - screenshot = Image.open(file_name) - - # offset = (rectangle[0], rectangle[1]) - if rectangle[1] + viewport_height > total_height: - offset = (rectangle[0], total_height - viewport_height) - else: - offset = (rectangle[0], rectangle[1]) - - # Log.Debug("Adding to stitched image with offset ({0}, {1})".format(offset[0],offset[1])) - stitched_image.paste(screenshot, offset) - - del screenshot - - os.remove(file_name) - - part = part + 1 - previous = rectangle - - stitched_image.save(file) - - # Log.Debug("Finishing chrome full page screenshot workaround ...") - - return True - - def get_el_value(self, el): - if not el: - return None - val = el.get_attribute("value") - # LOGGER.debug('peeked at field <%s>, value = %s' - # % (self.get_el_identifier(el), val)) - return val - - def get_el_text(self, el): - if not el: - return None - return el.text - - def set_el_value(self, el, value, js=False, slow_type=False): - if not el: - return False - prev_val = self.get_el_value(el) - if value is None: - el.clear() - return - el.clear() - - if js: - # doesn't work - rc = self.driver.execute_script("arguments[0].setAttribute('value', '%s');arguments[0].onchange();" % (value), el) - - else: - if slow_type: - try: - for ch in value: - el.send_keys(ch) - time.sleep(1) - except Exception as ex: - LOGGER.error("failed to send keys, element in unknown state!!: %s" % (ex)) - return False - else: - try: - el.send_keys(value) - except Exception as ex: - LOGGER.error("failed to send keys, element in unknown state!!: %s" % (ex)) - return False - - el_value = self.get_el_value(el) - if str(el_value) != str(value): - print(type(el_value), type(value)) - print("values didn't match.", value, el_value) - return False - # LOGGER.info('set field <%s> -> %s; previous = %s' - # % (self.get_el_identifier(el), el_value, prev_val)) - return True - - def get_select_value(self, select): - if not select: - LOGGER.error("tried to get select value of NULL element") - return None - try: - value = select.first_selected_option.get_attribute("value") - except selenium.common.exceptions.NoSuchElementException as ex: - value = None - return value - - def set_select_value(self, select, value=None, text=None): - if not select: - return False - # LOGGER.trace("setting select value (%s) for (%s)"\ - # % (value, self.get_el_identifier(select._el))) - if value: - try: - select.select_by_value(str(value)) - return True - except Exception as ex: - return False - elif text: - select.select_by_visible_text(text) - return False - - def set_checkbox(self, el, checked): - if not el: - return False - is_enabled = el.is_enabled() - is_selected = el.is_selected() - # print("enabled, selected, check:", is_enabled, is_selected, checked) - if not is_enabled: - return False - if checked: - if not is_selected: - # LOGGER.trace("checking unchecked box (%s)" % (self.get_el_identifier(el))) - self.click(el) - else: - if is_selected: - # LOGGER.trace("unchecking checked box (%s)" % (self.get_el_identifier(el))) - self.click(el) - val = self.get_checkbox_value(el) - if val != checked: - pass - # False != None - # print('checkbox not checked properly?', val, checked) - # input('checkbox not checked properly?') - # todo(mathew guest) assert checked - return True - - def get_checkbox_value(self, el, ignore_disabled=False): - """ - Returns the checked status of a checkbox element. True if enabled - and checked, False if disabled or unchecked. - """ - if not el: - return None - return (ignore_disabled or el.is_enabled()) and el.is_selected() - - def get_el_identifier(self, el): - """ - Returns a quick identifier to refer an element by. - """ - id_ = el.get_attribute("id") - name = el.get_attribute("name") - class_ = el.get_attribute("style") - if id_: - return id_ - if name: - return name - if class_: - return class_ - - def accept_alert(self, accept=True, timeout=ALERT_TIMEOUT): - """ - Looks for and tries to accept a javascript alert if it exists. - returns bool: whether or not an alert was accepted. - - There is a timeout penalty when calling this method if there is no alert. - """ - try: - selenium.webdriver.support.ui.WebDriverWait(self.driver, timeout).\ - until(selenium.webdriver.support.expected_conditions.alert_is_present(), - 'Timed out waiting alert' ) - alert = self.driver.switch_to_alert() - text = alert.text - if accept: - alert.accept() - else: - alert.dismiss() - LOGGER.trace('caught js alert: %s' % text) - return text - except selenium.common.exceptions.TimeoutException: - LOGGER.trace('no js alert present') - return False - - def is_element_stale(self, webelement): - """ - Checks if a webelement is stale. - @param webelement: A selenium webdriver webelement - """ - try: - webelement.tag_name - except selenium.common.exceptions.StaleElementReferenceException: - return True - except NameError: - pass - except: - pass - return False - From 25aaa5d4da50810c1580049eae36cdd90c945fcc Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 21:30:09 -0600 Subject: [PATCH 04/35] feat: pacing policies (HumanPacing default behavior, NoPacing for tests) --- src/wabot/pacing.py | 51 +++++++++++++++++++++++++++++++++++++++ tests/unit/test_pacing.py | 40 ++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/wabot/pacing.py create mode 100644 tests/unit/test_pacing.py diff --git a/src/wabot/pacing.py b/src/wabot/pacing.py new file mode 100644 index 0000000..c32c16b --- /dev/null +++ b/src/wabot/pacing.py @@ -0,0 +1,51 @@ +"""Pacing policies: how human-like the bot's interactions are. + +``HumanPacing`` reproduces legacy wabot stealth behavior (random delays +between actions, Gaussian click offsets). ``NoPacing`` is instant and +exact — used by the test suite and for trusted-site automation. +""" + +from __future__ import annotations + +import random + + +class NoPacing: + """Instant actions, exact clicks.""" + + def delay(self, action: str) -> float: + """Seconds to sleep before performing ``action``.""" + return 0.0 + + def click_offset(self, width: int, height: int) -> tuple[int, int] | None: + """Offset from the element center to click at, or None for a plain click.""" + return None + + +class HumanPacing(NoPacing): + """Random delays and Gaussian click offsets (anti-bot stealth).""" + + DELAYS: dict[str, tuple[float, float]] = { + "text": (3.0, 5.0), + "select": (6.0, 11.0), + "checkbox": (2.0, 3.0), + "navigate": (3.0, 6.0), + } + MIN_DIMENSION = 5 # elements thinner than this get a plain centered click + + def delay(self, action: str) -> float: + lo, hi = self.DELAYS.get(action, (0.0, 0.0)) + return random.uniform(lo, hi) + + def click_offset(self, width: int, height: int) -> tuple[int, int] | None: + if width < self.MIN_DIMENSION or height < self.MIN_DIMENSION: + return None + return (self._axis_offset(width), self._axis_offset(height)) + + @staticmethod + def _axis_offset(size: int) -> int: + # Selenium 4 measures offsets from the element's in-view center, so a + # human-looking click is a Gaussian around 0 clamped inside the element. + max_offset = size // 2 - 1 + offset = int(random.gauss(0.0, size / 7.0)) + return max(-max_offset, min(max_offset, offset)) diff --git a/tests/unit/test_pacing.py b/tests/unit/test_pacing.py new file mode 100644 index 0000000..5800f62 --- /dev/null +++ b/tests/unit/test_pacing.py @@ -0,0 +1,40 @@ +from wabot.pacing import HumanPacing, NoPacing + + +class TestNoPacing: + def test_delay_is_zero_for_every_action(self): + p = NoPacing() + assert p.delay("text") == 0.0 + assert p.delay("select") == 0.0 + assert p.delay("unknown-action") == 0.0 + + def test_click_offset_is_none(self): + assert NoPacing().click_offset(200, 50) is None + + +class TestHumanPacing: + def test_delays_fall_in_documented_ranges(self): + p = HumanPacing() + for _ in range(50): + assert 3.0 <= p.delay("text") <= 5.0 + assert 6.0 <= p.delay("select") <= 11.0 + assert 2.0 <= p.delay("checkbox") <= 3.0 + assert 3.0 <= p.delay("navigate") <= 6.0 + + def test_unknown_action_has_no_delay(self): + assert HumanPacing().delay("unknown-action") == 0.0 + + def test_click_offset_stays_inside_element(self): + p = HumanPacing() + for _ in range(200): + offset = p.click_offset(100, 40) + assert offset is not None + x, y = offset + # offsets are measured from the element CENTER (selenium 4) + assert -49 <= x <= 49 + assert -19 <= y <= 19 + + def test_tiny_elements_get_plain_click(self): + p = HumanPacing() + assert p.click_offset(4, 40) is None + assert p.click_offset(40, 4) is None From f5e358393dc1c25275fd6a2793d5b0b110bc792e Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 21:37:33 -0600 Subject: [PATCH 05/35] test: assert pacing actually varies; document NoPacing as policy base --- src/wabot/pacing.py | 7 ++++++- tests/unit/test_pacing.py | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/wabot/pacing.py b/src/wabot/pacing.py index c32c16b..852ef0d 100644 --- a/src/wabot/pacing.py +++ b/src/wabot/pacing.py @@ -11,7 +11,12 @@ import random class NoPacing: - """Instant actions, exact clicks.""" + """Instant actions, exact clicks. + + Also the base class for custom pacing policies; do not use + ``isinstance(x, NoPacing)`` to test whether pacing is disabled — + HumanPacing subclasses this. + """ def delay(self, action: str) -> float: """Seconds to sleep before performing ``action``.""" diff --git a/tests/unit/test_pacing.py b/tests/unit/test_pacing.py index 5800f62..a4981fe 100644 --- a/tests/unit/test_pacing.py +++ b/tests/unit/test_pacing.py @@ -24,15 +24,21 @@ class TestHumanPacing: def test_unknown_action_has_no_delay(self): assert HumanPacing().delay("unknown-action") == 0.0 + def test_delays_actually_vary(self): + assert len({HumanPacing().delay("text") for _ in range(50)}) > 1 + def test_click_offset_stays_inside_element(self): p = HumanPacing() + offsets = [] for _ in range(200): offset = p.click_offset(100, 40) assert offset is not None + offsets.append(offset) x, y = offset # offsets are measured from the element CENTER (selenium 4) assert -49 <= x <= 49 assert -19 <= y <= 19 + assert any(o != (0, 0) for o in offsets) def test_tiny_elements_get_plain_click(self): p = HumanPacing() 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 06/35] 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()) From 1909eb621ea943f5eee0d4b777c28b436860e68d Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 21:49:05 -0600 Subject: [PATCH 07/35] =?UTF-8?q?fix:=20harden=20SessionStore=20=E2=80=94?= =?UTF-8?q?=20per-record=20timestamp=20handling,=20atomic=20writes,=20robu?= =?UTF-8?q?st=20quarantine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-07-07-wabot-modernization.md | 112 ++++++++++++++++-- src/wabot/sessions.py | 50 ++++++-- tests/unit/test_sessions.py | 56 +++++++++ 3 files changed, 199 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/plans/2026-07-07-wabot-modernization.md b/docs/superpowers/plans/2026-07-07-wabot-modernization.md index 323dd7e..c52f0df 100644 --- a/docs/superpowers/plans/2026-07-07-wabot-modernization.md +++ b/docs/superpowers/plans/2026-07-07-wabot-modernization.md @@ -371,6 +371,62 @@ class TestSessionStore: assert default_store_path().name == "sessions.json" assert "wabot" in str(default_store_path()) + + def test_naive_created_at_is_treated_as_utc_not_crash(self, tmp_path): + # legacy/hand-written records may carry naive timestamps + import json + + path = tmp_path / "sessions.json" + record = make_record() + data = {"scraper1": {**record.__dict__, "created_at": "2999-01-01T00:00:00"}} + path.write_text(json.dumps(data)) + store = SessionStore(path=path) + assert store.get("scraper1") is not None # fresh: kept, assumed UTC + + def test_garbage_created_at_evicts_only_that_record(self, tmp_path): + import json + + path = tmp_path / "sessions.json" + good, bad = make_record(name="good"), make_record(name="bad") + data = { + "good": good.__dict__, + "bad": {**bad.__dict__, "created_at": "not-a-date"}, + } + path.write_text(json.dumps(data)) + store = SessionStore(path=path) + assert store.names() == ["good"] # no crash, bad evicted, file rewritten + assert "not-a-date" not in path.read_text() + + def test_null_created_at_evicts_only_that_record(self, tmp_path): + import json + + path = tmp_path / "sessions.json" + data = {"broken": {**make_record(name="broken").__dict__, "created_at": None}} + path.write_text(json.dumps(data)) + assert SessionStore(path=path).names() == [] + + def test_unknown_record_fields_are_ignored_not_fatal(self, tmp_path): + # a newer wabot may add fields; older versions must not quarantine the store + import json + + path = tmp_path / "sessions.json" + data = {"scraper1": {**make_record().__dict__, "future_field": 42}} + path.write_text(json.dumps(data)) + assert SessionStore(path=path).get("scraper1") is not None + + def test_repeat_corruption_with_existing_bad_file_does_not_crash(self, tmp_path): + path = tmp_path / "sessions.json" + (tmp_path / "sessions.json.bad").write_text("old corruption") + path.write_text("{ corrupt again") + store = SessionStore(path=path) + assert store.load() == {} + store.save(make_record()) + assert store.get("scraper1") is not None + + def test_write_is_atomic_no_tmp_leftover(self, tmp_path): + store = SessionStore(path=tmp_path / "sessions.json") + store.save(make_record()) + assert not (tmp_path / "sessions.json.tmp").exists() ``` - [ ] **Step 2: Run tests to verify they fail** @@ -395,6 +451,7 @@ from __future__ import annotations import dataclasses import json import logging +import os from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path @@ -417,6 +474,20 @@ class SessionRecord: service_port: int | None = None +_RECORD_FIELDS = {field.name for field in dataclasses.fields(SessionRecord)} + + +def _parse_created_at(value) -> datetime | None: + """Parse a record timestamp; None means unusable (treat as stale).""" + try: + created = datetime.fromisoformat(value) + except (ValueError, TypeError): + return None + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + return created + + def default_store_path() -> Path: return Path(platformdirs.user_data_dir("wabot")) / "sessions.json" @@ -433,16 +504,22 @@ class SessionStore: 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) + raw = json.loads(self.path.read_text(encoding="utf-8")) + records = { + name: SessionRecord(**{k: v for k, v in data.items() if k in _RECORD_FIELDS}) + for name, data in raw.items() + } + except (json.JSONDecodeError, TypeError, AttributeError, OSError): + self._quarantine() return {} fresh = {} for name, record in records.items(): - created = datetime.fromisoformat(record.created_at) + created = _parse_created_at(record.created_at) + if created is None: + LOGGER.warning( + "evicting session %r with unusable created_at %r", name, record.created_at + ) + continue if datetime.now(timezone.utc) - created >= self.max_age: LOGGER.info("evicting stale session %r (created %s)", name, record.created_at) continue @@ -451,6 +528,14 @@ class SessionStore: self._write(fresh) return fresh + def _quarantine(self) -> None: + bad = self.path.with_name(self.path.name + ".bad") + try: + self.path.replace(bad) # replace, not rename: works when .bad exists + LOGGER.warning("corrupt session store moved to %s; starting fresh", bad) + except OSError as ex: + LOGGER.warning("could not quarantine corrupt store %s: %s", self.path, ex) + def get(self, name: str) -> SessionRecord | None: return self.load().get(name) @@ -468,9 +553,14 @@ class SessionStore: return sorted(self.load()) def _write(self, records: dict[str, SessionRecord]) -> None: + # NOTE: save() is read-modify-write with no cross-process lock; at this + # library's scale (a handful of sessions on human timescales) a lost + # update is acceptable. A torn/partial file is NOT — hence atomic replace. 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)) + tmp = self.path.with_name(self.path.name + ".tmp") + tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8") + os.replace(tmp, self.path) ``` Note the corrupt-file test writes `sessions.json` + `.bad` → `sessions.json.bad`, which is why `_write` uses `with_name(self.path.name + ".bad")` (not `with_suffix`, which would replace `.json`). @@ -478,7 +568,7 @@ Note the corrupt-file test writes `sessions.json` + `.bad` → `sessions.json.ba - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/unit/test_sessions.py -v` -Expected: 8 passed +Expected: 14 passed - [ ] **Step 5: Commit** @@ -1052,7 +1142,7 @@ Run: `uv run pytest tests/unit/test_reattach.py -v` Expected: 5 passed Run: `uv run pytest` -Expected: all unit tests so far pass (pacing 6, sessions 8, hosts 17, reattach 5 = 36) +Expected: all unit tests so far pass (pacing 7, sessions 14, hosts 17, reattach 5 = 43) - [ ] **Step 5: Commit** @@ -2577,7 +2667,7 @@ Run: `uv run pytest tests/unit/test_api.py -v` Expected: 11 passed Run: `uv run pytest` -Expected: full unit suite passes (97 tests: pacing 6, sessions 8, hosts 17, reattach 5, fields 11, page 23, screenshot 3, browser 13, api 11) +Expected: full unit suite passes (104 tests: pacing 7, sessions 14, hosts 17, reattach 5, fields 11, page 23, screenshot 3, browser 13, api 11) - [ ] **Step 5: Commit** diff --git a/src/wabot/sessions.py b/src/wabot/sessions.py index 155f0f2..89a83bf 100644 --- a/src/wabot/sessions.py +++ b/src/wabot/sessions.py @@ -10,6 +10,7 @@ from __future__ import annotations import dataclasses import json import logging +import os from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path @@ -32,6 +33,20 @@ class SessionRecord: service_port: int | None = None +_RECORD_FIELDS = {field.name for field in dataclasses.fields(SessionRecord)} + + +def _parse_created_at(value) -> datetime | None: + """Parse a record timestamp; None means unusable (treat as stale).""" + try: + created = datetime.fromisoformat(value) + except (ValueError, TypeError): + return None + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + return created + + def default_store_path() -> Path: return Path(platformdirs.user_data_dir("wabot")) / "sessions.json" @@ -48,16 +63,22 @@ class SessionStore: 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) + raw = json.loads(self.path.read_text(encoding="utf-8")) + records = { + name: SessionRecord(**{k: v for k, v in data.items() if k in _RECORD_FIELDS}) + for name, data in raw.items() + } + except (json.JSONDecodeError, TypeError, AttributeError, OSError): + self._quarantine() return {} fresh = {} for name, record in records.items(): - created = datetime.fromisoformat(record.created_at) + created = _parse_created_at(record.created_at) + if created is None: + LOGGER.warning( + "evicting session %r with unusable created_at %r", name, record.created_at + ) + continue if datetime.now(timezone.utc) - created >= self.max_age: LOGGER.info("evicting stale session %r (created %s)", name, record.created_at) continue @@ -66,6 +87,14 @@ class SessionStore: self._write(fresh) return fresh + def _quarantine(self) -> None: + bad = self.path.with_name(self.path.name + ".bad") + try: + self.path.replace(bad) # replace, not rename: works when .bad exists + LOGGER.warning("corrupt session store moved to %s; starting fresh", bad) + except OSError as ex: + LOGGER.warning("could not quarantine corrupt store %s: %s", self.path, ex) + def get(self, name: str) -> SessionRecord | None: return self.load().get(name) @@ -83,6 +112,11 @@ class SessionStore: return sorted(self.load()) def _write(self, records: dict[str, SessionRecord]) -> None: + # NOTE: save() is read-modify-write with no cross-process lock; at this + # library's scale (a handful of sessions on human timescales) a lost + # update is acceptable. A torn/partial file is NOT — hence atomic replace. 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)) + tmp = self.path.with_name(self.path.name + ".tmp") + tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8") + os.replace(tmp, self.path) diff --git a/tests/unit/test_sessions.py b/tests/unit/test_sessions.py index 194f20b..c29433d 100644 --- a/tests/unit/test_sessions.py +++ b/tests/unit/test_sessions.py @@ -67,3 +67,59 @@ class TestSessionStore: assert default_store_path().name == "sessions.json" assert "wabot" in str(default_store_path()) + + def test_naive_created_at_is_treated_as_utc_not_crash(self, tmp_path): + # legacy/hand-written records may carry naive timestamps + import json + + path = tmp_path / "sessions.json" + record = make_record() + data = {"scraper1": {**record.__dict__, "created_at": "2999-01-01T00:00:00"}} + path.write_text(json.dumps(data)) + store = SessionStore(path=path) + assert store.get("scraper1") is not None # fresh: kept, assumed UTC + + def test_garbage_created_at_evicts_only_that_record(self, tmp_path): + import json + + path = tmp_path / "sessions.json" + good, bad = make_record(name="good"), make_record(name="bad") + data = { + "good": good.__dict__, + "bad": {**bad.__dict__, "created_at": "not-a-date"}, + } + path.write_text(json.dumps(data)) + store = SessionStore(path=path) + assert store.names() == ["good"] # no crash, bad evicted, file rewritten + assert "not-a-date" not in path.read_text() + + def test_null_created_at_evicts_only_that_record(self, tmp_path): + import json + + path = tmp_path / "sessions.json" + data = {"broken": {**make_record(name="broken").__dict__, "created_at": None}} + path.write_text(json.dumps(data)) + assert SessionStore(path=path).names() == [] + + def test_unknown_record_fields_are_ignored_not_fatal(self, tmp_path): + # a newer wabot may add fields; older versions must not quarantine the store + import json + + path = tmp_path / "sessions.json" + data = {"scraper1": {**make_record().__dict__, "future_field": 42}} + path.write_text(json.dumps(data)) + assert SessionStore(path=path).get("scraper1") is not None + + def test_repeat_corruption_with_existing_bad_file_does_not_crash(self, tmp_path): + path = tmp_path / "sessions.json" + (tmp_path / "sessions.json.bad").write_text("old corruption") + path.write_text("{ corrupt again") + store = SessionStore(path=path) + assert store.load() == {} + store.save(make_record()) + assert store.get("scraper1") is not None + + def test_write_is_atomic_no_tmp_leftover(self, tmp_path): + store = SessionStore(path=tmp_path / "sessions.json") + store.save(make_record()) + assert not (tmp_path / "sessions.json.tmp").exists() From 99cfe0337ca5101b76aeaa69c57d8f7a0c2d6c40 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 21:54:48 -0600 Subject: [PATCH 08/35] feat: options builder, /status liveness check, ExternalServer host --- src/wabot/hosts.py | 88 +++++++++++++++++++++++++++++++++++++ tests/unit/test_hosts.py | 93 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 src/wabot/hosts.py create mode 100644 tests/unit/test_hosts.py diff --git a/src/wabot/hosts.py b/src/wabot/hosts.py new file mode 100644 index 0000000..8b37da7 --- /dev/null +++ b/src/wabot/hosts.py @@ -0,0 +1,88 @@ +"""Driver hosts: the processes that keep a browser alive, and how to reach them. + +Persistence-capable browsers are always driven over the WebDriver wire +protocol (a URL). Two hosts provide that URL: ``ExternalServer`` (a +Selenium Grid or standalone driver the user runs) and ``ManagedService`` +(a chromedriver/geckodriver wabot spawns detached so it outlives Python). +""" + +from __future__ import annotations + +import json +import logging +import urllib.request + +from selenium import webdriver + +LOGGER = logging.getLogger("wabot") + +BROWSER_ALIASES = {"chrome": "chromium"} +DRIVER_BINARIES = {"chromium": "chromedriver", "firefox": "geckodriver"} + + +def normalize_browser(browser: str) -> str: + browser = BROWSER_ALIASES.get(browser, browser) + if browser not in DRIVER_BINARIES: + raise ValueError( + f"unsupported browser {browser!r}; use 'firefox' or 'chromium' (alias: 'chrome')" + ) + return browser + + +def build_options( + browser: str, *, headless: bool = False, user_agent: str | None = None +) -> webdriver.ChromeOptions | webdriver.FirefoxOptions: + """Browser-appropriate Options. UA defaults to the browser's own.""" + browser = normalize_browser(browser) + if browser == "chromium": + opts = webdriver.ChromeOptions() + binary = _chromium_binary() + if binary: + opts.binary_location = binary + if headless: + opts.add_argument("--headless=new") + if user_agent: + opts.add_argument(f"--user-agent={user_agent}") + return opts + opts = webdriver.FirefoxOptions() + if headless: + opts.add_argument("-headless") + if user_agent: + opts.set_preference("general.useragent.override", user_agent) + return opts + + +def _chromium_binary() -> str | None: + import shutil + + for name in ("chromium", "chromium-browser", "google-chrome", "chrome"): + found = shutil.which(name) + if found: + return found + return None + + +def service_alive(url: str, timeout: float = 2.0) -> bool: + """True if a WebDriver server answers GET /status at ``url``. + + Liveness only: geckodriver reports ready=false while its single session + is in use, so the ``ready`` flag is deliberately ignored. + """ + try: + with urllib.request.urlopen(f"{url.rstrip('/')}/status", timeout=timeout) as resp: + json.loads(resp.read()) + return resp.status == 200 + except (OSError, ValueError): + return False + + +class ExternalServer: + """A WebDriver server somebody else runs (Selenium Grid, bare driver).""" + + def __init__(self, url: str): + self.url = url.rstrip("/") + + def ensure_running(self) -> str: + if not service_alive(self.url): + raise ConnectionError(f"no WebDriver server answering at {self.url}/status") + return self.url diff --git a/tests/unit/test_hosts.py b/tests/unit/test_hosts.py new file mode 100644 index 0000000..68025a5 --- /dev/null +++ b/tests/unit/test_hosts.py @@ -0,0 +1,93 @@ +import http.server +import json +import threading + +import pytest + +from wabot.hosts import ExternalServer, build_options, normalize_browser, service_alive + + +class TestNormalizeBrowser: + def test_chrome_is_an_alias_for_chromium(self): + assert normalize_browser("chrome") == "chromium" + + def test_firefox_and_chromium_pass_through(self): + assert normalize_browser("firefox") == "firefox" + assert normalize_browser("chromium") == "chromium" + + def test_unknown_browser_raises(self): + with pytest.raises(ValueError, match="unsupported browser"): + normalize_browser("safari") + + +class TestBuildOptions: + def test_chromium_headless_and_user_agent(self): + opts = build_options("chromium", headless=True, user_agent="MyBot/1.0") + assert "--headless=new" in opts.arguments + assert "--user-agent=MyBot/1.0" in opts.arguments + + def test_chromium_defaults_have_no_headless_or_ua(self): + opts = build_options("chromium") + assert not any(a.startswith(("--headless", "--user-agent")) for a in opts.arguments) + + def test_firefox_headless_and_user_agent(self): + opts = build_options("firefox", headless=True, user_agent="MyBot/1.0") + assert "-headless" in opts.arguments + assert opts.preferences.get("general.useragent.override") == "MyBot/1.0" + + def test_options_type_matches_browser(self): + assert type(build_options("firefox")).__module__.startswith("selenium.webdriver.firefox") + assert type(build_options("chrome")).__module__.startswith("selenium.webdriver.chrome") + + +class _StatusHandler(http.server.BaseHTTPRequestHandler): + ready = True + + def do_GET(self): + if self.path == "/status": + body = json.dumps({"value": {"ready": self.ready, "message": ""}}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, *args): # keep test output clean + pass + + +@pytest.fixture +def status_server(): + server = http.server.HTTPServer(("127.0.0.1", 0), _StatusHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{server.server_address[1]}" + server.shutdown() + + +class TestServiceAlive: + def test_true_when_status_answers(self, status_server): + assert service_alive(status_server) is True + + def test_true_even_when_not_ready(self, status_server): + # geckodriver reports ready=false while its single session is in use; + # aliveness must not require ready==true + _StatusHandler.ready = False + try: + assert service_alive(status_server) is True + finally: + _StatusHandler.ready = True + + def test_false_when_nothing_listens(self): + assert service_alive("http://127.0.0.1:1") is False + + +class TestExternalServer: + def test_returns_url_when_alive(self, status_server): + assert ExternalServer(status_server + "/").ensure_running() == status_server + + def test_raises_when_dead(self): + with pytest.raises(ConnectionError, match="no WebDriver server"): + ExternalServer("http://127.0.0.1:1").ensure_running() From e9d307e020c02ab777baf8a3d4d7b727a85efb37 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 22:05:03 -0600 Subject: [PATCH 09/35] fix: service_alive returns False for non-HTTP responders --- .../plans/2026-07-07-wabot-modernization.md | 41 ++++++++++++++++--- src/wabot/hosts.py | 4 +- tests/unit/test_hosts.py | 28 +++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-07-07-wabot-modernization.md b/docs/superpowers/plans/2026-07-07-wabot-modernization.md index c52f0df..3be14ff 100644 --- a/docs/superpowers/plans/2026-07-07-wabot-modernization.md +++ b/docs/superpowers/plans/2026-07-07-wabot-modernization.md @@ -657,6 +657,7 @@ def status_server(): thread.start() yield f"http://127.0.0.1:{server.server_address[1]}" server.shutdown() + server.server_close() class TestServiceAlive: @@ -683,6 +684,33 @@ class TestExternalServer: def test_raises_when_dead(self): with pytest.raises(ConnectionError, match="no WebDriver server"): ExternalServer("http://127.0.0.1:1").ensure_running() + + +class TestServiceAliveNonHttpResponder: + def test_non_http_service_on_port_is_false_and_clean_error(self): + # wrong-port misconfiguration: some non-HTTP service answers the socket + import socket + + server = socket.create_server(("127.0.0.1", 0)) + port = server.getsockname()[1] + + def answer(count=2): + for _ in range(count): + try: + conn, _ = server.accept() + except OSError: + return + conn.sendall(b"SSH-2.0-OpenSSH_9.7\r\n") + conn.close() + + thread = threading.Thread(target=answer, daemon=True) + thread.start() + try: + assert service_alive(f"http://127.0.0.1:{port}", timeout=2.0) is False + with pytest.raises(ConnectionError, match="no WebDriver server"): + ExternalServer(f"http://127.0.0.1:{port}").ensure_running() + finally: + server.close() ``` - [ ] **Step 2: Run tests to verify they fail** @@ -705,6 +733,7 @@ Selenium Grid or standalone driver the user runs) and ``ManagedService`` from __future__ import annotations +import http.client import json import logging import urllib.request @@ -764,12 +793,13 @@ def service_alive(url: str, timeout: float = 2.0) -> bool: Liveness only: geckodriver reports ready=false while its single session is in use, so the ``ready`` flag is deliberately ignored. + The ``timeout`` bounds each socket operation, not total wall-clock time. """ try: with urllib.request.urlopen(f"{url.rstrip('/')}/status", timeout=timeout) as resp: json.loads(resp.read()) return resp.status == 200 - except (OSError, ValueError): + except (OSError, ValueError, http.client.HTTPException): return False @@ -788,7 +818,7 @@ class ExternalServer: - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/unit/test_hosts.py -v` -Expected: 12 passed +Expected: 13 passed - [ ] **Step 5: Commit** @@ -898,6 +928,7 @@ Expected: new tests FAIL — `ImportError: cannot import name 'ManagedService'`; Append to `src/wabot/hosts.py`, and extend the top import block to: ```python +import http.client import json import logging import os @@ -1006,7 +1037,7 @@ def stop_service(pid: int) -> bool: - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/unit/test_hosts.py -v` -Expected: 17 passed (12 from Task 4 + 5 new) +Expected: 18 passed (13 from Task 4 + 5 new) - [ ] **Step 5: Commit** @@ -1142,7 +1173,7 @@ Run: `uv run pytest tests/unit/test_reattach.py -v` Expected: 5 passed Run: `uv run pytest` -Expected: all unit tests so far pass (pacing 7, sessions 14, hosts 17, reattach 5 = 43) +Expected: all unit tests so far pass (pacing 7, sessions 14, hosts 18, reattach 5 = 44) - [ ] **Step 5: Commit** @@ -2667,7 +2698,7 @@ Run: `uv run pytest tests/unit/test_api.py -v` Expected: 11 passed Run: `uv run pytest` -Expected: full unit suite passes (104 tests: pacing 7, sessions 14, hosts 17, reattach 5, fields 11, page 23, screenshot 3, browser 13, api 11) +Expected: full unit suite passes (105 tests: pacing 7, sessions 14, hosts 18, reattach 5, fields 11, page 23, screenshot 3, browser 13, api 11) - [ ] **Step 5: Commit** diff --git a/src/wabot/hosts.py b/src/wabot/hosts.py index 8b37da7..311e4fb 100644 --- a/src/wabot/hosts.py +++ b/src/wabot/hosts.py @@ -8,6 +8,7 @@ Selenium Grid or standalone driver the user runs) and ``ManagedService`` from __future__ import annotations +import http.client import json import logging import urllib.request @@ -67,12 +68,13 @@ def service_alive(url: str, timeout: float = 2.0) -> bool: Liveness only: geckodriver reports ready=false while its single session is in use, so the ``ready`` flag is deliberately ignored. + The ``timeout`` bounds each socket operation, not total wall-clock time. """ try: with urllib.request.urlopen(f"{url.rstrip('/')}/status", timeout=timeout) as resp: json.loads(resp.read()) return resp.status == 200 - except (OSError, ValueError): + except (OSError, ValueError, http.client.HTTPException): return False diff --git a/tests/unit/test_hosts.py b/tests/unit/test_hosts.py index 68025a5..329e7aa 100644 --- a/tests/unit/test_hosts.py +++ b/tests/unit/test_hosts.py @@ -65,6 +65,7 @@ def status_server(): thread.start() yield f"http://127.0.0.1:{server.server_address[1]}" server.shutdown() + server.server_close() class TestServiceAlive: @@ -91,3 +92,30 @@ class TestExternalServer: def test_raises_when_dead(self): with pytest.raises(ConnectionError, match="no WebDriver server"): ExternalServer("http://127.0.0.1:1").ensure_running() + + +class TestServiceAliveNonHttpResponder: + def test_non_http_service_on_port_is_false_and_clean_error(self): + # wrong-port misconfiguration: some non-HTTP service answers the socket + import socket + + server = socket.create_server(("127.0.0.1", 0)) + port = server.getsockname()[1] + + def answer(count=2): + for _ in range(count): + try: + conn, _ = server.accept() + except OSError: + return + conn.sendall(b"SSH-2.0-OpenSSH_9.7\r\n") + conn.close() + + thread = threading.Thread(target=answer, daemon=True) + thread.start() + try: + assert service_alive(f"http://127.0.0.1:{port}", timeout=2.0) is False + with pytest.raises(ConnectionError, match="no WebDriver server"): + ExternalServer(f"http://127.0.0.1:{port}").ensure_running() + finally: + server.close() From f82b77ae62c7a1ac43db6b3bc8eb3c07659e46a6 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 22:08:32 -0600 Subject: [PATCH 10/35] feat: ManagedService spawns detached driver services that outlive python --- src/wabot/hosts.py | 97 +++++++++++++++++++++++++++++++++++++++- tests/unit/test_hosts.py | 85 ++++++++++++++++++++++++++++++++++- 2 files changed, 179 insertions(+), 3 deletions(-) diff --git a/src/wabot/hosts.py b/src/wabot/hosts.py index 311e4fb..0cf7b3a 100644 --- a/src/wabot/hosts.py +++ b/src/wabot/hosts.py @@ -11,8 +11,16 @@ from __future__ import annotations import http.client import json import logging +import os +import shutil +import signal +import socket +import subprocess +import time import urllib.request +from pathlib import Path +import platformdirs from selenium import webdriver LOGGER = logging.getLogger("wabot") @@ -54,8 +62,6 @@ def build_options( def _chromium_binary() -> str | None: - import shutil - for name in ("chromium", "chromium-browser", "google-chrome", "chrome"): found = shutil.which(name) if found: @@ -88,3 +94,90 @@ class ExternalServer: if not service_alive(self.url): raise ConnectionError(f"no WebDriver server answering at {self.url}/status") return self.url + + +def find_driver_binary(browser: str) -> str: + """Locate chromedriver/geckodriver: env override, PATH, then Selenium Manager.""" + name = DRIVER_BINARIES[normalize_browser(browser)] + override = os.environ.get(f"WABOT_{name.upper()}") + if override: + return override + found = shutil.which(name) + if found: + return found + try: + # Selenium Manager is bundled with selenium but explicitly beta + # ("may change") — keep it as a guarded fallback, never the primary. + from selenium.webdriver.common.selenium_manager import SeleniumManager + + args = ["--browser", "chrome" if browser == "chromium" else browser] + chromium = _chromium_binary() + if browser == "chromium" and chromium: + args += ["--browser-path", chromium] + return SeleniumManager().binary_paths(args)["driver_path"] + except Exception as ex: # noqa: BLE001 — beta API, failure modes unknown + raise FileNotFoundError( + f"could not find {name!r} on PATH (set WABOT_{name.upper()} to override); " + f"Selenium Manager fallback also failed: {ex}" + ) from ex + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +class ManagedService: + """A driver service wabot spawns detached, so it outlives this process.""" + + def __init__( + self, + browser: str, + log_dir: Path | None = None, + driver_binary: str | None = None, + startup_timeout: float = 15.0, + ): + self.browser = normalize_browser(browser) + self.log_dir = Path(log_dir) if log_dir else Path(platformdirs.user_data_dir("wabot")) + self.driver_binary = driver_binary + self.startup_timeout = startup_timeout + self.pid: int | None = None + self.port: int | None = None + + def ensure_running(self) -> str: + binary = self.driver_binary or find_driver_binary(self.browser) + self.port = _free_port() + url = f"http://127.0.0.1:{self.port}" + self.log_dir.mkdir(parents=True, exist_ok=True) + log_path = self.log_dir / f"{DRIVER_BINARIES[self.browser]}-{self.port}.log" + with open(log_path, "ab") as log: + process = subprocess.Popen( + [binary, f"--port={self.port}"], + stdout=log, + stderr=log, + stdin=subprocess.DEVNULL, + start_new_session=True, # own session: survives this python process + ) + self.pid = process.pid + LOGGER.info("spawned %s (pid %s) on %s, log %s", binary, self.pid, url, log_path) + deadline = time.monotonic() + self.startup_timeout + while time.monotonic() < deadline: + if service_alive(url): + return url + if process.poll() is not None: + break # exited early; fall through to the error + time.sleep(0.2) + raise TimeoutError( + f"{binary} did not answer /status on {url} within " + f"{self.startup_timeout}s (see {log_path})" + ) + + +def stop_service(pid: int) -> bool: + """SIGTERM a managed driver service. False if it was already gone.""" + try: + os.kill(pid, signal.SIGTERM) + return True + except (ProcessLookupError, PermissionError): + return False diff --git a/tests/unit/test_hosts.py b/tests/unit/test_hosts.py index 329e7aa..7686b1b 100644 --- a/tests/unit/test_hosts.py +++ b/tests/unit/test_hosts.py @@ -1,10 +1,22 @@ import http.server import json +import os +import stat +import sys import threading +import time import pytest -from wabot.hosts import ExternalServer, build_options, normalize_browser, service_alive +from wabot.hosts import ( + ExternalServer, + ManagedService, + build_options, + find_driver_binary, + normalize_browser, + service_alive, + stop_service, +) class TestNormalizeBrowser: @@ -119,3 +131,74 @@ class TestServiceAliveNonHttpResponder: ExternalServer(f"http://127.0.0.1:{port}").ensure_running() finally: server.close() + + +STUB_DRIVER = """\ +#!{python} +import http.server, json, sys +port = int(sys.argv[1].split("=", 1)[1]) +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = json.dumps({{"value": {{"ready": True, "message": ""}}}}).encode() + self.send_response(200); self.end_headers(); self.wfile.write(body) + def log_message(self, *a): pass +http.server.HTTPServer(("127.0.0.1", port), H).serve_forever() +""" + + +@pytest.fixture +def stub_driver(tmp_path): + path = tmp_path / "chromedriver" + path.write_text(STUB_DRIVER.format(python=sys.executable)) + path.chmod(path.stat().st_mode | stat.S_IEXEC) + return path + + +class TestFindDriverBinary: + def test_env_var_override_wins(self, monkeypatch): + monkeypatch.setenv("WABOT_CHROMEDRIVER", "/custom/chromedriver") + assert find_driver_binary("chromium") == "/custom/chromedriver" + + def test_path_lookup(self, monkeypatch, stub_driver): + monkeypatch.delenv("WABOT_CHROMEDRIVER", raising=False) + monkeypatch.setenv("PATH", str(stub_driver.parent)) + assert find_driver_binary("chromium") == str(stub_driver) + + +class TestManagedService: + def test_spawns_detached_and_answers_status(self, stub_driver, tmp_path): + svc = ManagedService("chromium", log_dir=tmp_path, driver_binary=str(stub_driver)) + url = svc.ensure_running() + try: + assert service_alive(url) + assert svc.pid is not None and svc.port is not None + assert url == f"http://127.0.0.1:{svc.port}" + # detached: the spawned process is in its own session, so it + # would survive this python process exiting + assert os.getsid(svc.pid) != os.getsid(os.getpid()) + assert (tmp_path / f"chromedriver-{svc.port}.log").exists() + finally: + stop_service(svc.pid) + + def test_startup_timeout_raises(self, tmp_path): + # /bin/true exits immediately and never serves /status + svc = ManagedService( + "chromium", log_dir=tmp_path, driver_binary="/bin/true", startup_timeout=1.0 + ) + with pytest.raises(TimeoutError, match="did not answer /status"): + svc.ensure_running() + + +class TestStopService: + def test_stops_a_live_process_and_reports_dead_ones(self, stub_driver, tmp_path): + svc = ManagedService("chromium", log_dir=tmp_path, driver_binary=str(stub_driver)) + svc.ensure_running() + assert stop_service(svc.pid) is True + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + try: + os.kill(svc.pid, 0) + except ProcessLookupError: + break + time.sleep(0.05) + assert stop_service(999999) is False # no such pid From 65d31e88210a0f2cbbf8dbc0443141becbd6e710 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 22:23:09 -0600 Subject: [PATCH 11/35] fix: ManagedService terminates unresponsive drivers; stop_service reaps children --- .../plans/2026-07-07-wabot-modernization.md | 97 ++++++++++++++++--- src/wabot/hosts.py | 52 ++++++++-- tests/unit/test_hosts.py | 38 +++++++- 3 files changed, 161 insertions(+), 26 deletions(-) diff --git a/docs/superpowers/plans/2026-07-07-wabot-modernization.md b/docs/superpowers/plans/2026-07-07-wabot-modernization.md index 3be14ff..7ed468e 100644 --- a/docs/superpowers/plans/2026-07-07-wabot-modernization.md +++ b/docs/superpowers/plans/2026-07-07-wabot-modernization.md @@ -840,7 +840,6 @@ Append to `tests/unit/test_hosts.py`. The tests use a stub "driver" executable ```python import os -import signal import stat import sys import time @@ -868,6 +867,21 @@ def stub_driver(tmp_path): return path +SILENT_DRIVER = """\ +#!{python} +import sys, time +time.sleep(300) # accepts --port=N like a real driver but never serves /status +""" + + +@pytest.fixture +def silent_driver(tmp_path): + path = tmp_path / "chromedriver" + path.write_text(SILENT_DRIVER.format(python=sys.executable)) + path.chmod(path.stat().st_mode | stat.S_IEXEC) + return path + + class TestFindDriverBinary: def test_env_var_override_wins(self, monkeypatch): monkeypatch.setenv("WABOT_CHROMEDRIVER", "/custom/chromedriver") @@ -894,13 +908,30 @@ class TestManagedService: finally: stop_service(svc.pid) - def test_startup_timeout_raises(self, tmp_path): - # /bin/true exits immediately and never serves /status + def test_early_exit_raises_with_returncode(self, tmp_path): + # /bin/true exits immediately: distinct from a slow timeout svc = ManagedService( - "chromium", log_dir=tmp_path, driver_binary="/bin/true", startup_timeout=1.0 + "chromium", log_dir=tmp_path, driver_binary="/bin/true", startup_timeout=5.0 + ) + with pytest.raises(RuntimeError, match="exited early"): + svc.ensure_running() + + def test_startup_timeout_terminates_and_does_not_leak(self, silent_driver, tmp_path): + svc = ManagedService( + "chromium", log_dir=tmp_path, driver_binary=str(silent_driver), startup_timeout=1.0 ) with pytest.raises(TimeoutError, match="did not answer /status"): svc.ensure_running() + # the unresponsive driver must have been terminated + reaped, not leaked + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + try: + os.kill(svc.pid, 0) + except ProcessLookupError: + break + time.sleep(0.05) + with pytest.raises(ProcessLookupError): + os.kill(svc.pid, 0) class TestStopService: @@ -948,8 +979,12 @@ from selenium import webdriver ```python def find_driver_binary(browser: str) -> str: - """Locate chromedriver/geckodriver: env override, PATH, then Selenium Manager.""" - name = DRIVER_BINARIES[normalize_browser(browser)] + """Locate chromedriver/geckodriver: env override, PATH, then Selenium Manager. + + The Selenium Manager fallback may download a driver over the network. + """ + browser = normalize_browser(browser) + name = DRIVER_BINARIES[browser] override = os.environ.get(f"WABOT_{name.upper()}") if override: return override @@ -966,7 +1001,8 @@ def find_driver_binary(browser: str) -> str: if browser == "chromium" and chromium: args += ["--browser-path", chromium] return SeleniumManager().binary_paths(args)["driver_path"] - except Exception as ex: # noqa: BLE001 — beta API, failure modes unknown + # Selenium Manager is beta; any failure folds into a clean FileNotFoundError + except Exception as ex: raise FileNotFoundError( f"could not find {name!r} on PATH (set WABOT_{name.upper()} to override); " f"Selenium Manager fallback also failed: {ex}" @@ -980,7 +1016,10 @@ def _free_port() -> int: class ManagedService: - """A driver service wabot spawns detached, so it outlives this process.""" + """A driver service wabot spawns detached, so it outlives this process. + + ensure_running() is not idempotent: each call spawns a new service. + """ def __init__( self, @@ -1016,9 +1055,22 @@ class ManagedService: while time.monotonic() < deadline: if service_alive(url): return url - if process.poll() is not None: - break # exited early; fall through to the error + returncode = process.poll() + if returncode is not None: + raise RuntimeError( + f"{binary} exited early with code {returncode} before " + f"answering /status on {url} (see {log_path})" + ) time.sleep(0.2) + # deadline expired but the driver is still running and unresponsive: + # terminate and reap it so we never leak a process that would otherwise + # outlive the interpreter. + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() raise TimeoutError( f"{binary} did not answer /status on {url} within " f"{self.startup_timeout}s (see {log_path})" @@ -1026,18 +1078,33 @@ class ManagedService: def stop_service(pid: int) -> bool: - """SIGTERM a managed driver service. False if it was already gone.""" + """SIGTERM a managed driver service. False if it was already gone. + + If the service is a child of THIS process (the same-process spawn→stop + lifecycle), it is reaped to avoid a zombie. In the cross-process reattach + case the driver is not our child and init reaps it. A driver that ignores + SIGTERM is left running (no SIGKILL escalation). + """ try: os.kill(pid, signal.SIGTERM) - return True except (ProcessLookupError, PermissionError): return False + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + try: + reaped, _ = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + break # not our child (cross-process) — init reaps it + if reaped: + break + time.sleep(0.05) + return True ``` - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/unit/test_hosts.py -v` -Expected: 18 passed (13 from Task 4 + 5 new) +Expected: 19 passed (13 from Task 4 + 6 new) - [ ] **Step 5: Commit** @@ -1173,7 +1240,7 @@ Run: `uv run pytest tests/unit/test_reattach.py -v` Expected: 5 passed Run: `uv run pytest` -Expected: all unit tests so far pass (pacing 7, sessions 14, hosts 18, reattach 5 = 44) +Expected: all unit tests so far pass (pacing 7, sessions 14, hosts 19, reattach 5 = 45) - [ ] **Step 5: Commit** @@ -2698,7 +2765,7 @@ Run: `uv run pytest tests/unit/test_api.py -v` Expected: 11 passed Run: `uv run pytest` -Expected: full unit suite passes (105 tests: pacing 7, sessions 14, hosts 18, reattach 5, fields 11, page 23, screenshot 3, browser 13, api 11) +Expected: full unit suite passes (106 tests: pacing 7, sessions 14, hosts 19, reattach 5, fields 11, page 23, screenshot 3, browser 13, api 11) - [ ] **Step 5: Commit** diff --git a/src/wabot/hosts.py b/src/wabot/hosts.py index 0cf7b3a..b6b70f9 100644 --- a/src/wabot/hosts.py +++ b/src/wabot/hosts.py @@ -97,8 +97,12 @@ class ExternalServer: def find_driver_binary(browser: str) -> str: - """Locate chromedriver/geckodriver: env override, PATH, then Selenium Manager.""" - name = DRIVER_BINARIES[normalize_browser(browser)] + """Locate chromedriver/geckodriver: env override, PATH, then Selenium Manager. + + The Selenium Manager fallback may download a driver over the network. + """ + browser = normalize_browser(browser) + name = DRIVER_BINARIES[browser] override = os.environ.get(f"WABOT_{name.upper()}") if override: return override @@ -115,7 +119,8 @@ def find_driver_binary(browser: str) -> str: if browser == "chromium" and chromium: args += ["--browser-path", chromium] return SeleniumManager().binary_paths(args)["driver_path"] - except Exception as ex: # noqa: BLE001 — beta API, failure modes unknown + # Selenium Manager is beta; any failure folds into a clean FileNotFoundError + except Exception as ex: raise FileNotFoundError( f"could not find {name!r} on PATH (set WABOT_{name.upper()} to override); " f"Selenium Manager fallback also failed: {ex}" @@ -129,7 +134,10 @@ def _free_port() -> int: class ManagedService: - """A driver service wabot spawns detached, so it outlives this process.""" + """A driver service wabot spawns detached, so it outlives this process. + + ensure_running() is not idempotent: each call spawns a new service. + """ def __init__( self, @@ -165,9 +173,22 @@ class ManagedService: while time.monotonic() < deadline: if service_alive(url): return url - if process.poll() is not None: - break # exited early; fall through to the error + returncode = process.poll() + if returncode is not None: + raise RuntimeError( + f"{binary} exited early with code {returncode} before " + f"answering /status on {url} (see {log_path})" + ) time.sleep(0.2) + # deadline expired but the driver is still running and unresponsive: + # terminate and reap it so we never leak a process that would otherwise + # outlive the interpreter. + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() raise TimeoutError( f"{binary} did not answer /status on {url} within " f"{self.startup_timeout}s (see {log_path})" @@ -175,9 +196,24 @@ class ManagedService: def stop_service(pid: int) -> bool: - """SIGTERM a managed driver service. False if it was already gone.""" + """SIGTERM a managed driver service. False if it was already gone. + + If the service is a child of THIS process (the same-process spawn→stop + lifecycle), it is reaped to avoid a zombie. In the cross-process reattach + case the driver is not our child and init reaps it. A driver that ignores + SIGTERM is left running (no SIGKILL escalation). + """ try: os.kill(pid, signal.SIGTERM) - return True except (ProcessLookupError, PermissionError): return False + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + try: + reaped, _ = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + break # not our child (cross-process) — init reaps it + if reaped: + break + time.sleep(0.05) + return True diff --git a/tests/unit/test_hosts.py b/tests/unit/test_hosts.py index 7686b1b..88971d7 100644 --- a/tests/unit/test_hosts.py +++ b/tests/unit/test_hosts.py @@ -154,6 +154,21 @@ def stub_driver(tmp_path): return path +SILENT_DRIVER = """\ +#!{python} +import sys, time +time.sleep(300) # accepts --port=N like a real driver but never serves /status +""" + + +@pytest.fixture +def silent_driver(tmp_path): + path = tmp_path / "chromedriver" + path.write_text(SILENT_DRIVER.format(python=sys.executable)) + path.chmod(path.stat().st_mode | stat.S_IEXEC) + return path + + class TestFindDriverBinary: def test_env_var_override_wins(self, monkeypatch): monkeypatch.setenv("WABOT_CHROMEDRIVER", "/custom/chromedriver") @@ -180,13 +195,30 @@ class TestManagedService: finally: stop_service(svc.pid) - def test_startup_timeout_raises(self, tmp_path): - # /bin/true exits immediately and never serves /status + def test_early_exit_raises_with_returncode(self, tmp_path): + # /bin/true exits immediately: distinct from a slow timeout svc = ManagedService( - "chromium", log_dir=tmp_path, driver_binary="/bin/true", startup_timeout=1.0 + "chromium", log_dir=tmp_path, driver_binary="/bin/true", startup_timeout=5.0 + ) + with pytest.raises(RuntimeError, match="exited early"): + svc.ensure_running() + + def test_startup_timeout_terminates_and_does_not_leak(self, silent_driver, tmp_path): + svc = ManagedService( + "chromium", log_dir=tmp_path, driver_binary=str(silent_driver), startup_timeout=1.0 ) with pytest.raises(TimeoutError, match="did not answer /status"): svc.ensure_running() + # the unresponsive driver must have been terminated + reaped, not leaked + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + try: + os.kill(svc.pid, 0) + except ProcessLookupError: + break + time.sleep(0.05) + with pytest.raises(ProcessLookupError): + os.kill(svc.pid, 0) class TestStopService: From 3cec3f647b3a4c8ac4bf4df2f2c5d9ae35444314 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 22:25:33 -0600 Subject: [PATCH 12/35] feat: session reattach via ReattachingRemote (adopts saved session id) --- src/wabot/hosts.py | 38 ++++++++++++++++++++++++++++++++++ tests/unit/test_reattach.py | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 tests/unit/test_reattach.py diff --git a/src/wabot/hosts.py b/src/wabot/hosts.py index b6b70f9..16f5c90 100644 --- a/src/wabot/hosts.py +++ b/src/wabot/hosts.py @@ -22,6 +22,9 @@ from pathlib import Path import platformdirs from selenium import webdriver +from selenium.common.exceptions import WebDriverException +from selenium.webdriver.remote.webdriver import WebDriver as RemoteWebDriver +from urllib3.exceptions import HTTPError as _Urllib3HTTPError LOGGER = logging.getLogger("wabot") @@ -217,3 +220,38 @@ def stop_service(pid: int) -> bool: break time.sleep(0.05) return True + + +class ReattachingRemote(RemoteWebDriver): + """A Remote driver that adopts an existing session instead of creating one. + + Selenium has no supported reattach API; this relies on two internals + that have been stable across 4.x (pin selenium <5): ``execute()`` + injects ``self.session_id`` into every command, and ``start_session`` + is the only place a NEW_SESSION request happens. + """ + + def __init__(self, command_executor: str, session_id: str, options): + # NOT self.session_id: the base __init__ resets that to None before + # calling start_session, so stash the id under a private name. + self._reattach_session_id = session_id + super().__init__(command_executor=command_executor, options=options) + + def start_session(self, capabilities: dict) -> None: + # Skip Command.NEW_SESSION entirely; adopt the saved session. + self.session_id = self._reattach_session_id + self.caps = capabilities # requested caps: fine for normal commands (no CDP/BiDi) + + +def attach(executor_url: str, session_id: str, browser: str): + """Return a live driver adopting the saved session, or None if it is dead.""" + driver = ReattachingRemote(executor_url, session_id, options=build_options(browser)) + try: + _ = driver.current_url # first real HTTP call; raises if the session is gone + return driver + except WebDriverException as ex: + LOGGER.warning("saved session %s is dead: %s", session_id, ex) + return None + except (OSError, _Urllib3HTTPError) as ex: # server unreachable (connection refused, timeout) + LOGGER.warning("no server at %s: %s", executor_url, ex) + return None diff --git a/tests/unit/test_reattach.py b/tests/unit/test_reattach.py new file mode 100644 index 0000000..ca677a5 --- /dev/null +++ b/tests/unit/test_reattach.py @@ -0,0 +1,41 @@ +from selenium.common.exceptions import WebDriverException + +from wabot.hosts import ReattachingRemote, attach, build_options + + +class TestReattachingRemote: + def test_adopts_session_id_without_creating_a_session(self): + driver = ReattachingRemote( + "http://127.0.0.1:1", "saved-session-id", options=build_options("chromium") + ) + # base __init__ resets session_id to None BEFORE start_session runs; + # our override must still win + assert driver.session_id == "saved-session-id" + assert isinstance(driver.caps, dict) + + def test_works_for_firefox_options_too(self): + driver = ReattachingRemote( + "http://127.0.0.1:1", "sid", options=build_options("firefox") + ) + assert driver.session_id == "sid" + + +class TestAttach: + def test_returns_driver_when_probe_succeeds(self, monkeypatch): + monkeypatch.setattr( + ReattachingRemote, "current_url", property(lambda self: "http://example.com") + ) + driver = attach("http://127.0.0.1:1", "sid", "chromium") + assert driver is not None + assert driver.session_id == "sid" + + def test_returns_none_when_session_is_dead(self, monkeypatch): + def boom(self): + raise WebDriverException("invalid session id") + + monkeypatch.setattr(ReattachingRemote, "current_url", property(boom)) + assert attach("http://127.0.0.1:1", "sid", "chromium") is None + + def test_returns_none_when_server_is_unreachable(self): + # no server on port 1: connection refused is immediate; attach must not raise + assert attach("http://127.0.0.1:1", "sid", "chromium") is None From 14281a07742c5886dc7c24941a84aca61c013056 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 22:35:44 -0600 Subject: [PATCH 13/35] fix: attach() closes executor on failure; guard session adoption --- .../plans/2026-07-07-wabot-modernization.md | 54 +++++++++++++------ src/wabot/hosts.py | 7 +++ tests/unit/test_reattach.py | 15 ++++++ 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/docs/superpowers/plans/2026-07-07-wabot-modernization.md b/docs/superpowers/plans/2026-07-07-wabot-modernization.md index 7ed468e..94e6ac3 100644 --- a/docs/superpowers/plans/2026-07-07-wabot-modernization.md +++ b/docs/superpowers/plans/2026-07-07-wabot-modernization.md @@ -1126,7 +1126,6 @@ git commit -m "feat: ManagedService spawns detached driver services that outlive `tests/unit/test_reattach.py`. Constructing a `Remote` performs **no HTTP until the first command** (verified), so adoption can be unit-tested without a server; the dead-session path monkeypatches the probe: ```python -import pytest from selenium.common.exceptions import WebDriverException from wabot.hosts import ReattachingRemote, attach, build_options @@ -1134,6 +1133,8 @@ from wabot.hosts import ReattachingRemote, attach, build_options class TestReattachingRemote: def test_adopts_session_id_without_creating_a_session(self): + # constructing against a closed port (127.0.0.1:1) proves no HTTP happens + # until the first command driver = ReattachingRemote( "http://127.0.0.1:1", "saved-session-id", options=build_options("chromium") ) @@ -1168,6 +1169,19 @@ class TestAttach: def test_returns_none_when_server_is_unreachable(self): # no server on port 1: connection refused is immediate; attach must not raise assert attach("http://127.0.0.1:1", "sid", "chromium") is None + + def test_dead_session_closes_executor(self, monkeypatch): + from selenium.webdriver.remote.remote_connection import RemoteConnection + + closed = [] + monkeypatch.setattr(RemoteConnection, "close", lambda self: closed.append(1)) + + def boom(self): + raise WebDriverException("invalid session id") + + monkeypatch.setattr(ReattachingRemote, "current_url", property(boom)) + assert attach("http://127.0.0.1:1", "sid", "chromium") is None + assert closed == [1] # executor was closed, not leaked ``` - [ ] **Step 2: Run tests to verify they fail** @@ -1182,8 +1196,14 @@ Append to `src/wabot/hosts.py`; add these imports to the top block: ```python from selenium.common.exceptions import WebDriverException from selenium.webdriver.remote.webdriver import WebDriver as RemoteWebDriver +from urllib3.exceptions import HTTPError as _Urllib3HTTPError ``` +(The `urllib3` import is the resolved form of the note below: selenium's HTTP +layer surfaces `test_returns_none_when_server_is_unreachable`'s connection +refusal as a `urllib3.exceptions.MaxRetryError`, which is **not** an `OSError`, +so it must be caught explicitly. This was confirmed against selenium 4.45.) + Appended code: ```python @@ -1201,6 +1221,11 @@ class ReattachingRemote(RemoteWebDriver): # calling start_session, so stash the id under a private name. self._reattach_session_id = session_id super().__init__(command_executor=command_executor, options=options) + if self.session_id != session_id: + raise RuntimeError( + "session adoption failed: selenium's start_session contract " + "changed; pin selenium <5 or update ReattachingRemote" + ) def start_session(self, capabilities: dict) -> None: # Skip Command.NEW_SESSION entirely; adopt the saved session. @@ -1216,31 +1241,28 @@ def attach(executor_url: str, session_id: str, browser: str): return driver except WebDriverException as ex: LOGGER.warning("saved session %s is dead: %s", session_id, ex) + driver.command_executor.close() # do not leak the keep-alive socket return None - except OSError as ex: # server itself unreachable (connection refused, timeout) + except (OSError, _Urllib3HTTPError) as ex: # server unreachable (connection refused, timeout) LOGGER.warning("no server at %s: %s", executor_url, ex) + driver.command_executor.close() # do not leak the keep-alive socket return None ``` -Note: selenium's HTTP layer (urllib3) can surface connection failures as -`urllib3.exceptions.HTTPError` subclasses that are **not** `OSError`. If -`test_returns_none_when_server_is_unreachable` fails with an unhandled -`MaxRetryError`, add it explicitly: - -```python -from urllib3.exceptions import HTTPError as _Urllib3HTTPError -``` - -and change the last except clause to `except (OSError, _Urllib3HTTPError) as ex:`. -Whichever variant makes the test pass is the correct final code. +`driver.quit()` cannot be used to release the executor on the failure paths: +on a dead session it issues a DELETE that re-raises. Closing the executor +directly (`driver.command_executor.close()`) releases urllib3's pooled +keep-alive connection without another round-trip. The `session_id` tripwire in +`__init__` raises (not `assert`, so it survives `python -O`) to turn a future +silent adoption failure into a loud one at construction time. - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/unit/test_reattach.py -v` -Expected: 5 passed +Expected: 6 passed Run: `uv run pytest` -Expected: all unit tests so far pass (pacing 7, sessions 14, hosts 19, reattach 5 = 45) +Expected: all unit tests so far pass (pacing 7, sessions 14, hosts 19, reattach 6 = 46) - [ ] **Step 5: Commit** @@ -2765,7 +2787,7 @@ Run: `uv run pytest tests/unit/test_api.py -v` Expected: 11 passed Run: `uv run pytest` -Expected: full unit suite passes (106 tests: pacing 7, sessions 14, hosts 19, reattach 5, fields 11, page 23, screenshot 3, browser 13, api 11) +Expected: full unit suite passes (107 tests: pacing 7, sessions 14, hosts 19, reattach 6, fields 11, page 23, screenshot 3, browser 13, api 11) - [ ] **Step 5: Commit** diff --git a/src/wabot/hosts.py b/src/wabot/hosts.py index 16f5c90..ce8dac5 100644 --- a/src/wabot/hosts.py +++ b/src/wabot/hosts.py @@ -236,6 +236,11 @@ class ReattachingRemote(RemoteWebDriver): # calling start_session, so stash the id under a private name. self._reattach_session_id = session_id super().__init__(command_executor=command_executor, options=options) + if self.session_id != session_id: + raise RuntimeError( + "session adoption failed: selenium's start_session contract " + "changed; pin selenium <5 or update ReattachingRemote" + ) def start_session(self, capabilities: dict) -> None: # Skip Command.NEW_SESSION entirely; adopt the saved session. @@ -251,7 +256,9 @@ def attach(executor_url: str, session_id: str, browser: str): return driver except WebDriverException as ex: LOGGER.warning("saved session %s is dead: %s", session_id, ex) + driver.command_executor.close() return None except (OSError, _Urllib3HTTPError) as ex: # server unreachable (connection refused, timeout) LOGGER.warning("no server at %s: %s", executor_url, ex) + driver.command_executor.close() return None diff --git a/tests/unit/test_reattach.py b/tests/unit/test_reattach.py index ca677a5..8b3c1c7 100644 --- a/tests/unit/test_reattach.py +++ b/tests/unit/test_reattach.py @@ -5,6 +5,8 @@ from wabot.hosts import ReattachingRemote, attach, build_options class TestReattachingRemote: def test_adopts_session_id_without_creating_a_session(self): + # constructing against a closed port (127.0.0.1:1) proves no HTTP happens + # until the first command driver = ReattachingRemote( "http://127.0.0.1:1", "saved-session-id", options=build_options("chromium") ) @@ -39,3 +41,16 @@ class TestAttach: def test_returns_none_when_server_is_unreachable(self): # no server on port 1: connection refused is immediate; attach must not raise assert attach("http://127.0.0.1:1", "sid", "chromium") is None + + def test_dead_session_closes_executor(self, monkeypatch): + from selenium.webdriver.remote.remote_connection import RemoteConnection + + closed = [] + monkeypatch.setattr(RemoteConnection, "close", lambda self: closed.append(1)) + + def boom(self): + raise WebDriverException("invalid session id") + + monkeypatch.setattr(ReattachingRemote, "current_url", property(boom)) + assert attach("http://127.0.0.1:1", "sid", "chromium") is None + assert closed == [1] # executor was closed, not leaked From 807b91e902f2c6308bc404139d71c04ac515fe56 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 22:37:33 -0600 Subject: [PATCH 14/35] feat: typed field wrappers with pacing (port of legacy fields.py) --- src/wabot/fields.py | 106 ++++++++++++++++++++++++++++++++++++++ tests/unit/test_fields.py | 87 +++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 src/wabot/fields.py create mode 100644 tests/unit/test_fields.py diff --git a/src/wabot/fields.py b/src/wabot/fields.py new file mode 100644 index 0000000..172a1ec --- /dev/null +++ b/src/wabot/fields.py @@ -0,0 +1,106 @@ +"""Typed wrappers around page elements. + +``page[key]`` returns one of these instead of a raw WebElement. Unknown +attribute access falls through to the underlying selenium element, so a +wrapper can be used anywhere an element can. ``NullField`` is the falsy +null-object returned for elements that could not be found, enabling +``if el:`` guards instead of exception handling. +""" + +from __future__ import annotations + +import logging +import time + +from selenium.webdriver.support.ui import Select + +LOGGER = logging.getLogger("wabot") + + +class PageObject: + """Base wrapper; subclasses add typed get/set behavior.""" + + def __init__(self, page, accessors=None, name=None): + self.page = page + self.accessors = accessors + self.name = name + self.el = self._locate() if accessors else None + + def _locate(self): + by, value = self.accessors + return self.page.driver.find_element(by=by, value=value) + + @property + def pacing(self): + return self.page.pacing + + def _pace(self, action: str) -> None: + delay = self.pacing.delay(action) + if delay: + time.sleep(delay) + + def __getattr__(self, name): + if name == "el": # guard: never recurse if __init__ didn't finish + raise AttributeError(name) + return getattr(self.el, name) + + def click(self): + return self.page.click(self.el) + + def click_and_go(self): + self._pace("navigate") + return self.page.click_and_go(self.el) + + +class TextField(PageObject): + def get_value(self): + return self.page.get_el_value(self.el) + + def set_value(self, value): + LOGGER.info("[%s] set_text(%r)", self.name, value) + self._pace("text") + return self.page.set_el_value(self.el, value) + + +class SelectField(PageObject): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.dropdown = Select(self.el) + + def get_value(self): + return self.page.get_select_value(self.dropdown) + + def set_value(self, value=None, text=None): + LOGGER.info("[%s] set_select(%r)", self.name, value if value is not None else text) + self._pace("select") + return self.page.set_select_value(self.dropdown, value=value, text=text) + + def select_by_index(self, index): + self._pace("select") + return self.dropdown.select_by_index(index) + + +class CheckField(PageObject): + def get_checked(self, ignore_disabled=False): + return self.page.get_checkbox_value(self.el, ignore_disabled) + + def set_checked(self, checked): + LOGGER.info("[%s] set_checked(%r)", self.name, checked) + self._pace("checkbox") + return self.page.set_checkbox(self.el, checked) + + +class NullField: + """Falsy placeholder for an element that was not found.""" + + def __init__(self, name=None): + # bypass __getattr__ tricks: plain attribute set + object.__setattr__(self, "name", name) + + def __bool__(self): + return False + + def __getattr__(self, attr): + raise AttributeError( + f"element {self.name!r} was not found on the page (attribute {attr!r})" + ) diff --git a/tests/unit/test_fields.py b/tests/unit/test_fields.py new file mode 100644 index 0000000..f757850 --- /dev/null +++ b/tests/unit/test_fields.py @@ -0,0 +1,87 @@ +from unittest.mock import MagicMock + +import pytest + +from wabot.fields import CheckField, NullField, PageObject, SelectField, TextField +from wabot.pacing import NoPacing + +ACCESSORS = ("id", "username") + + +@pytest.fixture +def page(): + page = MagicMock(name="page") + page.pacing = NoPacing() + return page + + +class TestPageObject: + def test_locates_element_via_page_driver(self, page): + obj = PageObject(page, accessors=ACCESSORS, name="username") + page.driver.find_element.assert_called_once_with(by="id", value="username") + assert obj.el is page.driver.find_element.return_value + + def test_unknown_attributes_fall_through_to_element(self, page): + obj = PageObject(page, accessors=ACCESSORS, name="username") + assert obj.tag_name is obj.el.tag_name + + def test_click_delegates_to_page(self, page): + obj = PageObject(page, accessors=ACCESSORS, name="username") + assert obj.click() is page.click.return_value + page.click.assert_called_once_with(obj.el) + + +class TestTextField: + def test_set_value_uses_page_helper(self, page): + field = TextField(page, accessors=ACCESSORS, name="username") + result = field.set_value("mathew") + page.set_el_value.assert_called_once_with(field.el, "mathew") + assert result is page.set_el_value.return_value + + def test_get_value_uses_page_helper(self, page): + field = TextField(page, accessors=ACCESSORS, name="username") + assert field.get_value() is page.get_el_value.return_value + + +class TestSelectField: + def test_wraps_element_in_selenium_select(self, page, monkeypatch): + import wabot.fields as fields_mod + + fake_select_cls = MagicMock(name="Select") + monkeypatch.setattr(fields_mod, "Select", fake_select_cls) + field = SelectField(page, accessors=("id", "state"), name="state") + fake_select_cls.assert_called_once_with(field.el) + assert field.dropdown is fake_select_cls.return_value + + def test_set_value_by_value_and_by_text(self, page, monkeypatch): + import wabot.fields as fields_mod + + monkeypatch.setattr(fields_mod, "Select", MagicMock()) + field = SelectField(page, accessors=("id", "state"), name="state") + field.set_value(value="CO") + page.set_select_value.assert_called_with(field.dropdown, value="CO", text=None) + field.set_value(text="Colorado") + page.set_select_value.assert_called_with(field.dropdown, value=None, text="Colorado") + + +class TestCheckField: + def test_set_and_get_checked(self, page): + field = CheckField(page, accessors=("id", "agree"), name="agree") + field.set_checked(True) + page.set_checkbox.assert_called_once_with(field.el, True) + field.get_checked() + page.get_checkbox_value.assert_called_once_with(field.el, False) + + +class TestNullField: + def test_is_falsy(self): + assert not NullField(name="missing") + + def test_attribute_access_raises_with_element_name(self): + with pytest.raises(AttributeError, match="missing"): + NullField(name="missing").click() + + def test_pattern_if_el_guards_work(self): + el = NullField(name="missing") + if el: + pytest.fail("NullField must be falsy") From 9e825ffd803d2db9f895b5084cecaa830aa19f27 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 22:45:46 -0600 Subject: [PATCH 15/35] test: assert field pacing wiring; simplify NullField --- .../plans/2026-07-07-wabot-modernization.md | 51 +++++++++++++++++-- src/wabot/fields.py | 3 +- tests/unit/test_fields.py | 44 ++++++++++++++++ 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-07-07-wabot-modernization.md b/docs/superpowers/plans/2026-07-07-wabot-modernization.md index 94e6ac3..949bbc0 100644 --- a/docs/superpowers/plans/2026-07-07-wabot-modernization.md +++ b/docs/superpowers/plans/2026-07-07-wabot-modernization.md @@ -1370,6 +1370,50 @@ class TestNullField: el = NullField(name="missing") if el: pytest.fail("NullField must be falsy") + + +class TestPacingWiring: + def _spy_page(self, page): + page.pacing = MagicMock() + page.pacing.delay.return_value = 0.0 + return page + + def test_text_set_value_paces_text(self, page): + self._spy_page(page) + TextField(page, accessors=ACCESSORS, name="username").set_value("x") + page.pacing.delay.assert_called_once_with("text") + + def test_select_set_value_paces_select(self, page, monkeypatch): + import wabot.fields as fields_mod + + monkeypatch.setattr(fields_mod, "Select", MagicMock()) + self._spy_page(page) + SelectField(page, accessors=("id", "state"), name="state").set_value(value="CO") + page.pacing.delay.assert_called_once_with("select") + + def test_select_by_index_paces_select(self, page, monkeypatch): + import wabot.fields as fields_mod + + monkeypatch.setattr(fields_mod, "Select", MagicMock()) + self._spy_page(page) + SelectField(page, accessors=("id", "state"), name="state").select_by_index(2) + page.pacing.delay.assert_called_once_with("select") + + def test_set_checked_paces_checkbox(self, page): + self._spy_page(page) + CheckField(page, accessors=("id", "agree"), name="agree").set_checked(True) + page.pacing.delay.assert_called_once_with("checkbox") + + def test_click_and_go_paces_navigate(self, page): + self._spy_page(page) + PageObject(page, accessors=ACCESSORS, name="username").click_and_go() + page.pacing.delay.assert_called_once_with("navigate") + + +class TestTruthiness: + def test_real_field_is_truthy(self, page): + # locks the other end of the `if el:` guard contract (NullField is falsy) + assert bool(TextField(page, accessors=ACCESSORS, name="username")) is True ``` - [ ] **Step 2: Run tests to verify they fail** @@ -1478,8 +1522,7 @@ class NullField: """Falsy placeholder for an element that was not found.""" def __init__(self, name=None): - # bypass __getattr__ tricks: plain attribute set - object.__setattr__(self, "name", name) + self.name = name def __bool__(self): return False @@ -1493,7 +1536,7 @@ class NullField: - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/unit/test_fields.py -v` -Expected: 11 passed +Expected: 17 passed - [ ] **Step 5: Commit** @@ -2787,7 +2830,7 @@ Run: `uv run pytest tests/unit/test_api.py -v` Expected: 11 passed Run: `uv run pytest` -Expected: full unit suite passes (107 tests: pacing 7, sessions 14, hosts 19, reattach 6, fields 11, page 23, screenshot 3, browser 13, api 11) +Expected: full unit suite passes (113 tests: pacing 7, sessions 14, hosts 19, reattach 6, fields 17, page 23, screenshot 3, browser 13, api 11) - [ ] **Step 5: Commit** diff --git a/src/wabot/fields.py b/src/wabot/fields.py index 172a1ec..1d93676 100644 --- a/src/wabot/fields.py +++ b/src/wabot/fields.py @@ -94,8 +94,7 @@ class NullField: """Falsy placeholder for an element that was not found.""" def __init__(self, name=None): - # bypass __getattr__ tricks: plain attribute set - object.__setattr__(self, "name", name) + self.name = name def __bool__(self): return False diff --git a/tests/unit/test_fields.py b/tests/unit/test_fields.py index f757850..9c6b758 100644 --- a/tests/unit/test_fields.py +++ b/tests/unit/test_fields.py @@ -85,3 +85,47 @@ class TestNullField: el = NullField(name="missing") if el: pytest.fail("NullField must be falsy") + + +class TestPacingWiring: + def _spy_page(self, page): + page.pacing = MagicMock() + page.pacing.delay.return_value = 0.0 + return page + + def test_text_set_value_paces_text(self, page): + self._spy_page(page) + TextField(page, accessors=ACCESSORS, name="username").set_value("x") + page.pacing.delay.assert_called_once_with("text") + + def test_select_set_value_paces_select(self, page, monkeypatch): + import wabot.fields as fields_mod + + monkeypatch.setattr(fields_mod, "Select", MagicMock()) + self._spy_page(page) + SelectField(page, accessors=("id", "state"), name="state").set_value(value="CO") + page.pacing.delay.assert_called_once_with("select") + + def test_select_by_index_paces_select(self, page, monkeypatch): + import wabot.fields as fields_mod + + monkeypatch.setattr(fields_mod, "Select", MagicMock()) + self._spy_page(page) + SelectField(page, accessors=("id", "state"), name="state").select_by_index(2) + page.pacing.delay.assert_called_once_with("select") + + def test_set_checked_paces_checkbox(self, page): + self._spy_page(page) + CheckField(page, accessors=("id", "agree"), name="agree").set_checked(True) + page.pacing.delay.assert_called_once_with("checkbox") + + def test_click_and_go_paces_navigate(self, page): + self._spy_page(page) + PageObject(page, accessors=ACCESSORS, name="username").click_and_go() + page.pacing.delay.assert_called_once_with("navigate") + + +class TestTruthiness: + def test_real_field_is_truthy(self, page): + # locks the other end of the `if el:` guard contract (NullField is falsy) + assert bool(TextField(page, accessors=ACCESSORS, name="username")) is True From 6af84687308a15627806948139518245f1b16729 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 22:48:18 -0600 Subject: [PATCH 16/35] feat: Page element maps with MRO inheritance and typed dispatch --- src/wabot/page.py | 81 +++++++++++++++++++++++++++++++++++ tests/unit/test_page.py | 95 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/wabot/page.py create mode 100644 tests/unit/test_page.py diff --git a/src/wabot/page.py b/src/wabot/page.py new file mode 100644 index 0000000..daef1c0 --- /dev/null +++ b/src/wabot/page.py @@ -0,0 +1,81 @@ +"""Page: the base class consumers subclass to model a website page. + +Pages declare a class-level ``elements`` map:: + + class Login(wabot.Page): + elements = { + "username": ("el", ("id", "username")), + "state": ("select", ("id", "state")), + "agree": ("checkbox", ("id", "agree")), + "rows": ("els", ("css selector", "tr.row")), + "special": (MyFieldClass, ("id", "special")), + } + +Lookup walks the MRO, so subclasses inherit and override parent maps. +``page[key]`` returns a typed field wrapper — never a raw WebElement — +and a falsy ``NullField`` when the element cannot be found. +""" + +from __future__ import annotations + +import logging + +from selenium.common.exceptions import NoSuchElementException + +from .fields import CheckField, NullField, SelectField, TextField + +LOGGER = logging.getLogger("wabot") + +ALERT_TIMEOUT = 3 +PAGE_LOAD_TIMEOUT = 10 + +FIELD_TYPES = {"el": TextField, "select": SelectField, "checkbox": CheckField} + + +class Page: + elements: dict = {} + + def __init__(self, browser): + self.browser = browser + self.driver = browser.driver + LOGGER.info("loaded page %s", type(self).__name__) + + @property + def pacing(self): + return self.browser.pacing + + def verify(self) -> bool: + """Override to gate ``Browser.set_page`` (return False to refuse).""" + return True + + def find_element_locators(self, key): + """First match for ``key`` walking the class hierarchy (MRO order).""" + for cls in type(self).__mro__: + locators = getattr(cls, "elements", {}).get(key) + if locators: + return locators + return None + + def get_proxy(self, key): + locators = self.find_element_locators(key) + if not locators: + LOGGER.warning("element not in page map: %s", key) + return NullField(name=key) + obj_type, accessors = locators[0], locators[1] + try: + if obj_type == "els": + by, value = accessors + return self.driver.find_elements(by=by, value=value) + field_cls = FIELD_TYPES.get(obj_type) + if field_cls is None and isinstance(obj_type, type): + field_cls = obj_type + if field_cls is not None: + return field_cls(page=self, accessors=accessors, name=key) + except NoSuchElementException: + LOGGER.warning("element %r not present on current page", key) + return NullField(name=key) + LOGGER.error("unknown element type for %r: %r", key, obj_type) + return NullField(name=key) + + def __getitem__(self, key): + return self.get_proxy(key) diff --git a/tests/unit/test_page.py b/tests/unit/test_page.py new file mode 100644 index 0000000..58b8475 --- /dev/null +++ b/tests/unit/test_page.py @@ -0,0 +1,95 @@ +from unittest.mock import MagicMock + +from selenium.common.exceptions import NoSuchElementException + +from wabot.fields import CheckField, NullField, SelectField, TextField +from wabot.pacing import NoPacing +from wabot.page import Page + + +class BasePage(Page): + elements = { + "username": ("el", ("id", "username")), + "state": ("select", ("id", "state")), + "agree": ("checkbox", ("id", "agree")), + "rows": ("els", ("css selector", "tr.row")), + } + + +class ChildPage(BasePage): + elements = { + "username": ("el", ("id", "user_name_v2")), # override + "extra": ("el", ("id", "extra")), + } + + +def make_browser(): + browser = MagicMock(name="browser") + browser.pacing = NoPacing() + return browser + + +class TestElementResolution: + def test_own_elements_found(self): + page = BasePage(make_browser()) + assert page.find_element_locators("username") == ("el", ("id", "username")) + + def test_mro_walk_inherits_parent_elements(self): + page = ChildPage(make_browser()) + assert page.find_element_locators("state") == ("select", ("id", "state")) + assert page.find_element_locators("extra") == ("el", ("id", "extra")) + + def test_child_overrides_parent(self): + page = ChildPage(make_browser()) + assert page.find_element_locators("username") == ("el", ("id", "user_name_v2")) + + def test_missing_key_returns_none(self): + assert BasePage(make_browser()).find_element_locators("nope") is None + + +class TestGetProxy: + def test_typed_dispatch(self): + page = BasePage(make_browser()) + assert isinstance(page["username"], TextField) + assert isinstance(page["agree"], CheckField) + + def test_select_dispatch(self, monkeypatch): + import wabot.fields as fields_mod + + monkeypatch.setattr(fields_mod, "Select", MagicMock()) + page = BasePage(make_browser()) + assert isinstance(page["state"], SelectField) + + def test_els_returns_find_elements_result(self): + browser = make_browser() + page = BasePage(browser) + result = page["rows"] + browser.driver.find_elements.assert_called_once_with( + by="css selector", value="tr.row" + ) + assert result is browser.driver.find_elements.return_value + + def test_unknown_key_returns_falsy_nullfield(self): + el = BasePage(make_browser())["nope"] + assert isinstance(el, NullField) + assert not el + + def test_element_not_on_page_returns_nullfield(self): + browser = make_browser() + browser.driver.find_element.side_effect = NoSuchElementException("gone") + el = BasePage(browser)["username"] + assert isinstance(el, NullField) + + def test_custom_field_class_dispatch(self): + class MyField(TextField): + pass + + class CustomPage(Page): + elements = {"thing": (MyField, ("id", "thing"))} + + assert isinstance(CustomPage(make_browser())["thing"], MyField) + + +class TestVerify: + def test_default_verify_is_true(self): + assert BasePage(make_browser()).verify() is True From fbb7d4114aeb31c43e6c3158536698ff98231ff0 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 22:58:05 -0600 Subject: [PATCH 17/35] fix: graceful handling of malformed element declarations; quiet absent-element log --- .../plans/2026-07-07-wabot-modernization.md | 59 ++++++++++++++++--- src/wabot/page.py | 23 +++++++- tests/unit/test_page.py | 28 +++++++++ 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/plans/2026-07-07-wabot-modernization.md b/docs/superpowers/plans/2026-07-07-wabot-modernization.md index 949bbc0..c311269 100644 --- a/docs/superpowers/plans/2026-07-07-wabot-modernization.md +++ b/docs/superpowers/plans/2026-07-07-wabot-modernization.md @@ -1563,8 +1563,8 @@ from unittest.mock import MagicMock from selenium.common.exceptions import NoSuchElementException from wabot.fields import CheckField, NullField, SelectField, TextField -from wabot.page import Page from wabot.pacing import NoPacing +from wabot.page import Page class BasePage(Page): @@ -1606,6 +1606,14 @@ class TestElementResolution: def test_missing_key_returns_none(self): assert BasePage(make_browser()).find_element_locators("nope") is None + def test_empty_override_still_inherits_ancestors(self): + class Grandchild(ChildPage): + elements = {} # declares nothing; must still resolve ancestor keys + + page = Grandchild(make_browser()) + assert page.find_element_locators("state") == ("select", ("id", "state")) + assert page.find_element_locators("extra") == ("el", ("id", "extra")) + class TestGetProxy: def test_typed_dispatch(self): @@ -1649,6 +1657,26 @@ class TestGetProxy: assert isinstance(CustomPage(make_browser())["thing"], MyField) + def test_malformed_locator_missing_accessors_returns_nullfield(self): + class BadPage(Page): + elements = {"x": ("el",)} # missing the accessors half + + el = BadPage(make_browser())["x"] + assert isinstance(el, NullField) + assert not el + + def test_malformed_accessors_arity_returns_nullfield(self): + class BadPage(Page): + elements = {"x": ("els", ("id",))} # accessors not a (by, value) pair + + assert isinstance(BadPage(make_browser())["x"], NullField) + + def test_unknown_type_returns_nullfield(self): + class BadPage(Page): + elements = {"x": ("selct", ("id", "x"))} # typo'd type string + + assert isinstance(BadPage(make_browser())["x"], NullField) + class TestVerify: def test_default_verify_is_true(self): @@ -1679,8 +1707,9 @@ Pages declare a class-level ``elements`` map:: } Lookup walks the MRO, so subclasses inherit and override parent maps. -``page[key]`` returns a typed field wrapper — never a raw WebElement — -and a falsy ``NullField`` when the element cannot be found. +``page[key]`` returns a typed field wrapper (except the ``els`` type, which +returns a raw list of WebElements) and a falsy ``NullField`` when the element +cannot be found. """ from __future__ import annotations @@ -1728,7 +1757,23 @@ class Page: if not locators: LOGGER.warning("element not in page map: %s", key) return NullField(name=key) + if not isinstance(locators, (tuple, list)) or len(locators) < 2: + LOGGER.error( + "malformed element %r: expected (type, accessors), got %r", key, locators + ) + return NullField(name=key) obj_type, accessors = locators[0], locators[1] + # built-in types need a (by, value) accessors pair; a custom field class + # manages its own accessors, so only the built-ins are validated here. + if (obj_type in FIELD_TYPES or obj_type == "els") and ( + not isinstance(accessors, (tuple, list)) or len(accessors) != 2 + ): + LOGGER.error( + "malformed accessors for element %r: expected (by, value), got %r", + key, + accessors, + ) + return NullField(name=key) try: if obj_type == "els": by, value = accessors @@ -1739,7 +1784,7 @@ class Page: if field_cls is not None: return field_cls(page=self, accessors=accessors, name=key) except NoSuchElementException: - LOGGER.warning("element %r not present on current page", key) + LOGGER.debug("element %r not present on current page", key) return NullField(name=key) LOGGER.error("unknown element type for %r: %r", key, obj_type) return NullField(name=key) @@ -1751,7 +1796,7 @@ class Page: - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/unit/test_page.py -v` -Expected: 12 passed +Expected: 15 passed - [ ] **Step 5: Commit** @@ -2090,7 +2135,7 @@ Append these methods to `class Page`: - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/unit/test_page.py -v` -Expected: 23 passed (12 from Task 8 + 11 new) +Expected: 26 passed (15 from Task 8 + 11 new) - [ ] **Step 5: Commit** @@ -2830,7 +2875,7 @@ Run: `uv run pytest tests/unit/test_api.py -v` Expected: 11 passed Run: `uv run pytest` -Expected: full unit suite passes (113 tests: pacing 7, sessions 14, hosts 19, reattach 6, fields 17, page 23, screenshot 3, browser 13, api 11) +Expected: full unit suite passes (116 tests: pacing 7, sessions 14, hosts 19, reattach 6, fields 17, page 26, screenshot 3, browser 13, api 11) - [ ] **Step 5: Commit** diff --git a/src/wabot/page.py b/src/wabot/page.py index daef1c0..7b2120f 100644 --- a/src/wabot/page.py +++ b/src/wabot/page.py @@ -12,8 +12,9 @@ Pages declare a class-level ``elements`` map:: } Lookup walks the MRO, so subclasses inherit and override parent maps. -``page[key]`` returns a typed field wrapper — never a raw WebElement — -and a falsy ``NullField`` when the element cannot be found. +``page[key]`` returns a typed field wrapper (except the ``els`` type, which +returns a raw list of WebElements) and a falsy ``NullField`` when the element +cannot be found. """ from __future__ import annotations @@ -61,7 +62,23 @@ class Page: if not locators: LOGGER.warning("element not in page map: %s", key) return NullField(name=key) + if not isinstance(locators, (tuple, list)) or len(locators) < 2: + LOGGER.error( + "malformed element %r: expected (type, accessors), got %r", key, locators + ) + return NullField(name=key) obj_type, accessors = locators[0], locators[1] + # built-in types need a (by, value) accessors pair; a custom field class + # manages its own accessors, so only the built-ins are validated here. + if (obj_type in FIELD_TYPES or obj_type == "els") and ( + not isinstance(accessors, (tuple, list)) or len(accessors) != 2 + ): + LOGGER.error( + "malformed accessors for element %r: expected (by, value), got %r", + key, + accessors, + ) + return NullField(name=key) try: if obj_type == "els": by, value = accessors @@ -72,7 +89,7 @@ class Page: if field_cls is not None: return field_cls(page=self, accessors=accessors, name=key) except NoSuchElementException: - LOGGER.warning("element %r not present on current page", key) + LOGGER.debug("element %r not present on current page", key) return NullField(name=key) LOGGER.error("unknown element type for %r: %r", key, obj_type) return NullField(name=key) diff --git a/tests/unit/test_page.py b/tests/unit/test_page.py index 58b8475..13b16d8 100644 --- a/tests/unit/test_page.py +++ b/tests/unit/test_page.py @@ -46,6 +46,14 @@ class TestElementResolution: def test_missing_key_returns_none(self): assert BasePage(make_browser()).find_element_locators("nope") is None + def test_empty_override_still_inherits_ancestors(self): + class Grandchild(ChildPage): + elements = {} # declares nothing; must still resolve ancestor keys + + page = Grandchild(make_browser()) + assert page.find_element_locators("state") == ("select", ("id", "state")) + assert page.find_element_locators("extra") == ("el", ("id", "extra")) + class TestGetProxy: def test_typed_dispatch(self): @@ -89,6 +97,26 @@ class TestGetProxy: assert isinstance(CustomPage(make_browser())["thing"], MyField) + def test_malformed_locator_missing_accessors_returns_nullfield(self): + class BadPage(Page): + elements = {"x": ("el",)} # missing the accessors half + + el = BadPage(make_browser())["x"] + assert isinstance(el, NullField) + assert not el + + def test_malformed_accessors_arity_returns_nullfield(self): + class BadPage(Page): + elements = {"x": ("els", ("id",))} # accessors not a (by, value) pair + + assert isinstance(BadPage(make_browser())["x"], NullField) + + def test_unknown_type_returns_nullfield(self): + class BadPage(Page): + elements = {"x": ("selct", ("id", "x"))} # typo'd type string + + assert isinstance(BadPage(make_browser())["x"], NullField) + class TestVerify: def test_default_verify_is_true(self): From c99b9509f41e8e218aeca9cff3b26cd5ebad57f9 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 23:01:09 -0600 Subject: [PATCH 18/35] feat: Page interaction helpers (paced clicks, forms, alerts, waits) --- src/wabot/page.py | 184 +++++++++++++++++++++++++++++++++++++++- src/wabot/screenshot.py | 1 + tests/unit/test_page.py | 104 ++++++++++++++++++++++- 3 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 src/wabot/screenshot.py diff --git a/src/wabot/page.py b/src/wabot/page.py index 7b2120f..51b89ea 100644 --- a/src/wabot/page.py +++ b/src/wabot/page.py @@ -20,8 +20,17 @@ cannot be found. from __future__ import annotations import logging +import time -from selenium.common.exceptions import NoSuchElementException +from selenium.common.exceptions import ( + NoSuchElementException, + StaleElementReferenceException, + TimeoutException, + WebDriverException, +) +from selenium.webdriver import ActionChains +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support.ui import WebDriverWait from .fields import CheckField, NullField, SelectField, TextField @@ -96,3 +105,176 @@ class Page: def __getitem__(self, key): return self.get_proxy(key) + + # ---- clicking ----------------------------------------------------- + + def click(self, el, clickable_timeout: float = 10.0) -> bool: + """Click, at a pacing-chosen offset when the policy asks for one.""" + if not el: + LOGGER.warning("refusing to click null element") + return False + try: + size = el.size + except StaleElementReferenceException: + LOGGER.error("failed to click element: stale reference") + return False + if not self._wait_clickable(el, clickable_timeout): + return False + offset = self.pacing.click_offset(size["width"], size["height"]) + try: + if offset is None: + el.click() + else: + x, y = offset # measured from element center (selenium 4) + ActionChains(self.driver).move_to_element_with_offset(el, x, y).click().perform() + return True + except WebDriverException as ex: + LOGGER.error("click failed: %s", ex) + return False + + def _wait_clickable(self, el, timeout: float) -> bool: + deadline = time.monotonic() + timeout + while True: + try: + if el.is_displayed() and el.is_enabled(): + return True + except StaleElementReferenceException: + return False + if time.monotonic() >= deadline: + LOGGER.error("element never became clickable") + return False + time.sleep(0.2) + + def click_and_go(self, el) -> bool: + """Click, accept any alert, then wait for the page to change.""" + if not self.click(el): + return False + self.accept_alert() + return self._wait_for_element_to_go_stale(el) + + def _wait_for_element_to_go_stale(self, el) -> bool: + try: + WebDriverWait(self.driver, PAGE_LOAD_TIMEOUT).until( + lambda _driver: self.is_element_stale(el) + ) + return True + except TimeoutException: + LOGGER.error("timed out waiting for page load") + return False + + def is_element_stale(self, el) -> bool: + try: + _ = el.tag_name + return False + except StaleElementReferenceException: + return True + + # ---- alerts ------------------------------------------------------- + + def accept_alert(self, accept: bool = True, timeout: float = ALERT_TIMEOUT): + """Accept (or dismiss) a JS alert if one appears within ``timeout``. + + Returns the alert text, or False if no alert appeared. There is a + timeout penalty when no alert exists. + """ + try: + WebDriverWait(self.driver, timeout).until(EC.alert_is_present()) + except TimeoutException: + LOGGER.debug("no js alert present") + return False + alert = self.driver.switch_to.alert + text = alert.text + if accept: + alert.accept() + else: + alert.dismiss() + LOGGER.debug("handled js alert: %s", text) + return text + + # ---- form helpers ------------------------------------------------- + + def get_el_value(self, el): + if not el: + return None + return el.get_attribute("value") + + def get_el_text(self, el): + if not el: + return None + return el.text + + def set_el_value(self, el, value, slow_type: bool = False) -> bool: + if not el: + return False + el.clear() + if value is None: + return True + try: + if slow_type: + for char in str(value): + el.send_keys(char) + time.sleep(self.pacing.delay("text") / 10.0) + else: + el.send_keys(value) + except WebDriverException as ex: + LOGGER.error("failed to send keys, element in unknown state: %s", ex) + return False + actual = self.get_el_value(el) + if str(actual) != str(value): + LOGGER.error("field value mismatch: wanted %r, field has %r", value, actual) + return False + return True + + def get_select_value(self, select): + if not select: + LOGGER.error("tried to get select value of null element") + return None + try: + return select.first_selected_option.get_attribute("value") + except NoSuchElementException: + return None + + def set_select_value(self, select, value=None, text=None) -> bool: + if not select: + return False + try: + if value is not None: + select.select_by_value(str(value)) + return True + if text is not None: + select.select_by_visible_text(text) + return True + except (NoSuchElementException, WebDriverException) as ex: + LOGGER.error("failed to set select: %s", ex) + return False + + def set_checkbox(self, el, checked: bool) -> bool: + if not el: + return False + if not el.is_enabled(): + return False + if el.is_selected() != checked: + self.click(el) + return True + + def get_checkbox_value(self, el, ignore_disabled: bool = False): + """True if (enabled and) checked; None for a null element.""" + if not el: + return None + return (ignore_disabled or el.is_enabled()) and el.is_selected() + + def get_el_identifier(self, el): + """A quick human-readable identifier for logging.""" + for attr in ("id", "name", "class"): + value = el.get_attribute(attr) + if value: + return value + return el.tag_name + + # ---- screenshots ---------------------------------------------------- + + def save_screenshot(self, filename: str) -> bool: + from . import screenshot as screenshot_mod + + LOGGER.info("saving full-page screenshot: %s", filename) + return screenshot_mod.save_full_page(self.driver, filename) diff --git a/src/wabot/screenshot.py b/src/wabot/screenshot.py new file mode 100644 index 0000000..e7de733 --- /dev/null +++ b/src/wabot/screenshot.py @@ -0,0 +1 @@ +"""Full-page screenshots. Implemented in the screenshot task.""" diff --git a/tests/unit/test_page.py b/tests/unit/test_page.py index 13b16d8..c26b4ca 100644 --- a/tests/unit/test_page.py +++ b/tests/unit/test_page.py @@ -2,8 +2,9 @@ from unittest.mock import MagicMock from selenium.common.exceptions import NoSuchElementException +import wabot.page as page_mod from wabot.fields import CheckField, NullField, SelectField, TextField -from wabot.pacing import NoPacing +from wabot.pacing import HumanPacing, NoPacing from wabot.page import Page @@ -121,3 +122,104 @@ class TestGetProxy: class TestVerify: def test_default_verify_is_true(self): assert BasePage(make_browser()).verify() is True + + +class PlainPage(Page): + elements = {} + + +def make_element(width=100, height=30, displayed=True, enabled=True): + el = MagicMock(name="element") + el.size = {"width": width, "height": height} + el.is_displayed.return_value = displayed + el.is_enabled.return_value = enabled + return el + + +class TestClick: + def test_refuses_null_element(self): + assert PlainPage(make_browser()).click(None) is False + assert PlainPage(make_browser()).click(NullField(name="x")) is False + + def test_plain_click_when_pacing_gives_no_offset(self): + page = PlainPage(make_browser()) # NoPacing -> click_offset None + el = make_element() + assert page.click(el) is True + el.click.assert_called_once_with() + + def test_offset_click_uses_action_chains(self, monkeypatch): + browser = make_browser() + browser.pacing = HumanPacing() + chains = MagicMock(name="ActionChains") + monkeypatch.setattr(page_mod, "ActionChains", chains) + page = PlainPage(browser) + el = make_element(width=100, height=40) + assert page.click(el) is True + chains.assert_called_once_with(browser.driver) + args = chains.return_value.move_to_element_with_offset.call_args.args + assert args[0] is el + # offsets are center-relative and must stay inside the element + assert -49 <= args[1] <= 49 and -19 <= args[2] <= 19 + chains.return_value.move_to_element_with_offset.return_value.click.return_value.perform.assert_called_once() + + def test_never_clickable_element_fails(self): + page = PlainPage(make_browser()) + el = make_element(displayed=False) + assert page.click(el, clickable_timeout=0.3) is False + el.click.assert_not_called() + + +class TestFormHelpers: + def test_set_el_value_clears_types_and_verifies(self): + page = PlainPage(make_browser()) + el = make_element() + el.get_attribute.return_value = "mathew" + assert page.set_el_value(el, "mathew") is True + el.clear.assert_called_once_with() + el.send_keys.assert_called_once_with("mathew") + + def test_set_el_value_returns_false_on_mismatch(self): + page = PlainPage(make_browser()) + el = make_element() + el.get_attribute.return_value = "wrong" + assert page.set_el_value(el, "mathew") is False + + def test_set_el_value_none_just_clears(self): + page = PlainPage(make_browser()) + el = make_element() + assert page.set_el_value(el, None) is True + el.clear.assert_called_once_with() + el.send_keys.assert_not_called() + + def test_get_el_value_and_text_handle_null(self): + page = PlainPage(make_browser()) + assert page.get_el_value(None) is None + assert page.get_el_text(None) is None + + def test_set_checkbox_clicks_only_when_state_differs(self): + page = PlainPage(make_browser()) + el = make_element() + el.is_selected.return_value = False + page.set_checkbox(el, True) + el.click.assert_called_once() + el.click.reset_mock() + el.is_selected.return_value = True + page.set_checkbox(el, True) + el.click.assert_not_called() + + +class TestStaleness: + def test_fresh_element_is_not_stale(self): + page = PlainPage(make_browser()) + assert page.is_element_stale(make_element()) is False + + def test_stale_element_is_detected(self): + from selenium.common.exceptions import StaleElementReferenceException + + class StaleEl: # NOT a MagicMock: mutating type(mock) would poison every mock + @property + def tag_name(self): + raise StaleElementReferenceException("stale") + + page = PlainPage(make_browser()) + assert page.is_element_stale(StaleEl()) is True From 2adb7b22365da6a56ca7cfc82d9a06103d5c4f1d Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 23:12:11 -0600 Subject: [PATCH 19/35] fix: set_checkbox propagates click failure; coerce set_el_value; cover alerts/selects --- .../plans/2026-07-07-wabot-modernization.md | 96 ++++++++++++++++--- src/wabot/page.py | 10 +- tests/unit/test_page.py | 73 ++++++++++++++ 3 files changed, 164 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/plans/2026-07-07-wabot-modernization.md b/docs/superpowers/plans/2026-07-07-wabot-modernization.md index c311269..ea1d10b 100644 --- a/docs/superpowers/plans/2026-07-07-wabot-modernization.md +++ b/docs/superpowers/plans/2026-07-07-wabot-modernization.md @@ -1815,13 +1815,12 @@ git commit -m "feat: Page element maps with MRO inheritance and typed dispatch" - [ ] **Step 1: Write the failing tests** -Append to `tests/unit/test_page.py`: +Add `import wabot.page as page_mod` to the top-of-file import block and fold +`HumanPacing` into the existing `from wabot.pacing import NoPacing` (keeping the +imports at the top avoids ruff `E402`), then append these test classes to +`tests/unit/test_page.py`: ```python -import wabot.page as page_mod -from wabot.pacing import HumanPacing - - class PlainPage(Page): elements = {} @@ -1921,6 +1920,79 @@ class TestStaleness: page = PlainPage(make_browser()) assert page.is_element_stale(StaleEl()) is True + + +class TestAlert: + def test_accept_alert_accepts_and_returns_text(self, monkeypatch): + browser = make_browser() + page = PlainPage(browser) + monkeypatch.setattr(page_mod, "WebDriverWait", MagicMock()) # until() won't raise + alert = browser.driver.switch_to.alert + alert.text = "confirm?" + assert page.accept_alert() == "confirm?" + alert.accept.assert_called_once_with() + + def test_accept_alert_can_dismiss(self, monkeypatch): + browser = make_browser() + page = PlainPage(browser) + monkeypatch.setattr(page_mod, "WebDriverWait", MagicMock()) + alert = browser.driver.switch_to.alert + alert.text = "confirm?" + page.accept_alert(accept=False) + alert.dismiss.assert_called_once_with() + + def test_accept_alert_returns_false_when_no_alert(self, monkeypatch): + from selenium.common.exceptions import TimeoutException + + page = PlainPage(make_browser()) + wait = MagicMock() + wait.return_value.until.side_effect = TimeoutException() + monkeypatch.setattr(page_mod, "WebDriverWait", wait) + assert page.accept_alert(timeout=0) is False + + +class TestSelectHelpers: + def test_get_select_value(self): + select = MagicMock() + select.first_selected_option.get_attribute.return_value = "CO" + assert PlainPage(make_browser()).get_select_value(select) == "CO" + + def test_get_select_value_null_returns_none(self): + assert PlainPage(make_browser()).get_select_value(None) is None + + def test_set_select_value_by_value_including_zero(self): + # legacy `if value:` skipped a valid 0/"" — verify the fix + select = MagicMock() + assert PlainPage(make_browser()).set_select_value(select, value=0) is True + select.select_by_value.assert_called_once_with("0") + + def test_set_select_value_by_text(self): + select = MagicMock() + assert PlainPage(make_browser()).set_select_value(select, text="Colorado") is True + select.select_by_visible_text.assert_called_once_with("Colorado") + + +class TestCheckboxAndCoercion: + def test_set_checkbox_returns_false_when_click_fails(self, monkeypatch): + page = PlainPage(make_browser()) + el = make_element() + el.is_enabled.return_value = True + el.is_selected.return_value = False # needs toggling + monkeypatch.setattr(page, "click", lambda e: False) # click fails + assert page.set_checkbox(el, True) is False + + def test_set_checkbox_disabled_returns_false(self): + page = PlainPage(make_browser()) + el = make_element() + el.is_enabled.return_value = False + assert page.set_checkbox(el, True) is False + + def test_set_el_value_coerces_non_str(self): + page = PlainPage(make_browser()) + el = make_element() + el.get_attribute.return_value = "42" + assert page.set_el_value(el, 42) is True + el.send_keys.assert_called_once_with("42") ``` - [ ] **Step 2: Run tests to verify they fail** @@ -1965,13 +2037,13 @@ Append these methods to `class Page`: if not el: LOGGER.warning("refusing to click null element") return False + if not self._wait_clickable(el, clickable_timeout): + return False try: size = el.size except StaleElementReferenceException: LOGGER.error("failed to click element: stale reference") return False - if not self._wait_clickable(el, clickable_timeout): - return False offset = self.pacing.click_offset(size["width"], size["height"]) try: if offset is None: @@ -2067,11 +2139,13 @@ Append these methods to `class Page`: el.send_keys(char) time.sleep(self.pacing.delay("text") / 10.0) else: - el.send_keys(value) + el.send_keys(str(value)) except WebDriverException as ex: LOGGER.error("failed to send keys, element in unknown state: %s", ex) return False actual = self.get_el_value(el) + # verify the field holds exactly what we typed (strict: fields that + # normalize input will read back as a mismatch) if str(actual) != str(value): LOGGER.error("field value mismatch: wanted %r, field has %r", value, actual) return False @@ -2106,7 +2180,7 @@ Append these methods to `class Page`: if not el.is_enabled(): return False if el.is_selected() != checked: - self.click(el) + return self.click(el) # propagate: a failed click is a failed set return True def get_checkbox_value(self, el, ignore_disabled: bool = False): @@ -2135,7 +2209,7 @@ Append these methods to `class Page`: - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/unit/test_page.py -v` -Expected: 26 passed (15 from Task 8 + 11 new) +Expected: 36 passed (15 from Task 8 + 21 new) - [ ] **Step 5: Commit** @@ -2875,7 +2949,7 @@ Run: `uv run pytest tests/unit/test_api.py -v` Expected: 11 passed Run: `uv run pytest` -Expected: full unit suite passes (116 tests: pacing 7, sessions 14, hosts 19, reattach 6, fields 17, page 26, screenshot 3, browser 13, api 11) +Expected: full unit suite passes (126 tests: pacing 7, sessions 14, hosts 19, reattach 6, fields 17, page 36, screenshot 3, browser 13, api 11) - [ ] **Step 5: Commit** diff --git a/src/wabot/page.py b/src/wabot/page.py index 51b89ea..d286ef1 100644 --- a/src/wabot/page.py +++ b/src/wabot/page.py @@ -113,13 +113,13 @@ class Page: if not el: LOGGER.warning("refusing to click null element") return False + if not self._wait_clickable(el, clickable_timeout): + return False try: size = el.size except StaleElementReferenceException: LOGGER.error("failed to click element: stale reference") return False - if not self._wait_clickable(el, clickable_timeout): - return False offset = self.pacing.click_offset(size["width"], size["height"]) try: if offset is None: @@ -215,11 +215,13 @@ class Page: el.send_keys(char) time.sleep(self.pacing.delay("text") / 10.0) else: - el.send_keys(value) + el.send_keys(str(value)) except WebDriverException as ex: LOGGER.error("failed to send keys, element in unknown state: %s", ex) return False actual = self.get_el_value(el) + # verify the field holds exactly what we typed (strict: fields that + # normalize input will read back as a mismatch) if str(actual) != str(value): LOGGER.error("field value mismatch: wanted %r, field has %r", value, actual) return False @@ -254,7 +256,7 @@ class Page: if not el.is_enabled(): return False if el.is_selected() != checked: - self.click(el) + return self.click(el) # propagate: a failed click is a failed set return True def get_checkbox_value(self, el, ignore_disabled: bool = False): diff --git a/tests/unit/test_page.py b/tests/unit/test_page.py index c26b4ca..99823aa 100644 --- a/tests/unit/test_page.py +++ b/tests/unit/test_page.py @@ -223,3 +223,76 @@ class TestStaleness: page = PlainPage(make_browser()) assert page.is_element_stale(StaleEl()) is True + + +class TestAlert: + def test_accept_alert_accepts_and_returns_text(self, monkeypatch): + browser = make_browser() + page = PlainPage(browser) + monkeypatch.setattr(page_mod, "WebDriverWait", MagicMock()) # until() won't raise + alert = browser.driver.switch_to.alert + alert.text = "confirm?" + assert page.accept_alert() == "confirm?" + alert.accept.assert_called_once_with() + + def test_accept_alert_can_dismiss(self, monkeypatch): + browser = make_browser() + page = PlainPage(browser) + monkeypatch.setattr(page_mod, "WebDriverWait", MagicMock()) + alert = browser.driver.switch_to.alert + alert.text = "confirm?" + page.accept_alert(accept=False) + alert.dismiss.assert_called_once_with() + + def test_accept_alert_returns_false_when_no_alert(self, monkeypatch): + from selenium.common.exceptions import TimeoutException + + page = PlainPage(make_browser()) + wait = MagicMock() + wait.return_value.until.side_effect = TimeoutException() + monkeypatch.setattr(page_mod, "WebDriverWait", wait) + assert page.accept_alert(timeout=0) is False + + +class TestSelectHelpers: + def test_get_select_value(self): + select = MagicMock() + select.first_selected_option.get_attribute.return_value = "CO" + assert PlainPage(make_browser()).get_select_value(select) == "CO" + + def test_get_select_value_null_returns_none(self): + assert PlainPage(make_browser()).get_select_value(None) is None + + def test_set_select_value_by_value_including_zero(self): + # legacy `if value:` skipped a valid 0/"" — verify the fix + select = MagicMock() + assert PlainPage(make_browser()).set_select_value(select, value=0) is True + select.select_by_value.assert_called_once_with("0") + + def test_set_select_value_by_text(self): + select = MagicMock() + assert PlainPage(make_browser()).set_select_value(select, text="Colorado") is True + select.select_by_visible_text.assert_called_once_with("Colorado") + + +class TestCheckboxAndCoercion: + def test_set_checkbox_returns_false_when_click_fails(self, monkeypatch): + page = PlainPage(make_browser()) + el = make_element() + el.is_enabled.return_value = True + el.is_selected.return_value = False # needs toggling + monkeypatch.setattr(page, "click", lambda e: False) # click fails + assert page.set_checkbox(el, True) is False + + def test_set_checkbox_disabled_returns_false(self): + page = PlainPage(make_browser()) + el = make_element() + el.is_enabled.return_value = False + assert page.set_checkbox(el, True) is False + + def test_set_el_value_coerces_non_str(self): + page = PlainPage(make_browser()) + el = make_element() + el.get_attribute.return_value = "42" + assert page.set_el_value(el, 42) is True + el.send_keys.assert_called_once_with("42") From ab50a984a8a656cebe9520cf4da06bb48f93cb23 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 23:14:53 -0600 Subject: [PATCH 20/35] feat: full-page screenshots (firefox raw command, chromium PIL stitch) --- src/wabot/screenshot.py | 73 ++++++++++++++++++++++++++++++++++- tests/unit/test_screenshot.py | 68 ++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_screenshot.py diff --git a/src/wabot/screenshot.py b/src/wabot/screenshot.py index e7de733..6dbdff7 100644 --- a/src/wabot/screenshot.py +++ b/src/wabot/screenshot.py @@ -1 +1,72 @@ -"""Full-page screenshots. Implemented in the screenshot task.""" +"""Full-page screenshots for both browser families. + +Firefox: geckodriver has a native endpoint, but selenium only exposes it +on ``webdriver.Firefox`` — not on ``Remote`` — so the raw command is +registered by hand and works for both. + +Chromium: no full-page endpoint exists; the page is captured viewport by +viewport while scrolling, then stitched with PIL (port of the legacy +workaround, using in-memory PNGs instead of temp files). +""" + +from __future__ import annotations + +import base64 +import logging +import time +from io import BytesIO + +from PIL import Image + +LOGGER = logging.getLogger("wabot") + +FULL_PAGE_SCREENSHOT = "fullPageScreenshot" +_SCROLL_SETTLE_SECONDS = 0.2 + + +def save_full_page(driver, filename: str) -> bool: + """Save a full-page PNG of the current page to ``filename``.""" + if driver.capabilities.get("browserName") == "firefox": + return _firefox_full_page(driver, filename) + return _stitch_chromium(driver, filename) + + +def _firefox_full_page(driver, filename: str) -> bool: + driver.command_executor._commands.setdefault( + FULL_PAGE_SCREENSHOT, ("GET", "/session/$sessionId/moz/screenshot/full") + ) + b64 = driver.execute(FULL_PAGE_SCREENSHOT)["value"] + with open(filename, "wb") as fp: + fp.write(base64.b64decode(b64)) + return True + + +def _stitch_chromium(driver, filename: str) -> bool: + total_width = driver.execute_script("return document.body.parentNode.scrollWidth") + total_height = driver.execute_script("return document.body.parentNode.scrollHeight") + viewport_width = driver.execute_script("return document.documentElement.clientWidth") + viewport_height = driver.execute_script("return window.innerHeight") + + if total_width <= viewport_width and total_height <= viewport_height: + with open(filename, "wb") as fp: + fp.write(driver.get_screenshot_as_png()) + return True + + stitched = Image.new("RGB", (total_width, total_height)) + y = 0 + while y < total_height: + x = 0 + while x < total_width: + driver.execute_script(f"window.scrollTo({x}, {y})") + time.sleep(_SCROLL_SETTLE_SECONDS) + tile = Image.open(BytesIO(driver.get_screenshot_as_png())) + # the last row/column can't scroll a full viewport: paste aligned + # to the bottom/right edge instead of duplicating content + paste_x = min(x, total_width - viewport_width) + paste_y = min(y, total_height - viewport_height) + stitched.paste(tile, (max(paste_x, 0), max(paste_y, 0))) + x += viewport_width + y += viewport_height + stitched.save(filename) + LOGGER.debug("stitched %sx%s screenshot -> %s", total_width, total_height, filename) + return True diff --git a/tests/unit/test_screenshot.py b/tests/unit/test_screenshot.py new file mode 100644 index 0000000..617f129 --- /dev/null +++ b/tests/unit/test_screenshot.py @@ -0,0 +1,68 @@ +import base64 +from io import BytesIO +from unittest.mock import MagicMock + +from PIL import Image + +from wabot.screenshot import FULL_PAGE_SCREENSHOT, save_full_page + + +def png_bytes(width, height, color=(200, 30, 30)): + buf = BytesIO() + Image.new("RGB", (width, height), color).save(buf, format="PNG") + return buf.getvalue() + + +class TestFirefoxPath: + def test_registers_raw_command_and_decodes_base64(self, tmp_path): + driver = MagicMock(name="driver") + driver.capabilities = {"browserName": "firefox"} + driver.command_executor._commands = {} + driver.execute.return_value = {"value": base64.b64encode(png_bytes(50, 80)).decode()} + out = tmp_path / "shot.png" + assert save_full_page(driver, str(out)) is True + # helper methods only exist on webdriver.Firefox, so the raw + # geckodriver command must have been registered for Remote support + assert driver.command_executor._commands[FULL_PAGE_SCREENSHOT] == ( + "GET", + "/session/$sessionId/moz/screenshot/full", + ) + driver.execute.assert_called_once_with(FULL_PAGE_SCREENSHOT) + assert Image.open(out).size == (50, 80) + + +class TestChromiumPath: + def test_single_viewport_page_is_saved_directly(self, tmp_path): + driver = MagicMock(name="driver") + driver.capabilities = {"browserName": "chrome"} + # page fits in one viewport: total == viewport + driver.execute_script.side_effect = lambda script: { + "return document.body.parentNode.scrollWidth": 100, + "return document.body.parentNode.scrollHeight": 60, + "return document.documentElement.clientWidth": 100, + "return window.innerHeight": 60, + }.get(script) + driver.get_screenshot_as_png.return_value = png_bytes(100, 60) + out = tmp_path / "shot.png" + assert save_full_page(driver, str(out)) is True + assert Image.open(out).size == (100, 60) + + def test_tall_page_is_stitched_from_tiles(self, tmp_path): + driver = MagicMock(name="driver") + driver.capabilities = {"browserName": "chrome"} + driver.execute_script.side_effect = lambda script: { + "return document.body.parentNode.scrollWidth": 100, + "return document.body.parentNode.scrollHeight": 150, # 3 tiles of 60 + "return document.documentElement.clientWidth": 100, + "return window.innerHeight": 60, + }.get(script) + driver.get_screenshot_as_png.return_value = png_bytes(100, 60) + out = tmp_path / "shot.png" + assert save_full_page(driver, str(out)) is True + assert Image.open(out).size == (100, 150) + # scrolled at least twice beyond the initial position + scroll_calls = [ + c for c in driver.execute_script.call_args_list + if c.args and str(c.args[0]).startswith("window.scrollTo") + ] + assert len(scroll_calls) >= 2 From cafacee2b9d5f68e9f1301e353fbcec7dd60be35 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 23:23:17 -0600 Subject: [PATCH 21/35] test: verify screenshot seam alignment; release tile buffers --- .../plans/2026-07-07-wabot-modernization.md | 60 ++++++++++++++++--- src/wabot/screenshot.py | 14 +++-- tests/unit/test_screenshot.py | 42 +++++++++++++ 3 files changed, 102 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/plans/2026-07-07-wabot-modernization.md b/docs/superpowers/plans/2026-07-07-wabot-modernization.md index ea1d10b..8a5a3cc 100644 --- a/docs/superpowers/plans/2026-07-07-wabot-modernization.md +++ b/docs/superpowers/plans/2026-07-07-wabot-modernization.md @@ -2298,6 +2298,48 @@ class TestChromiumPath: if c.args and str(c.args[0]).startswith("window.scrollTo") ] assert len(scroll_calls) >= 2 + + def test_tall_page_stitches_tiles_bottom_aligned(self, tmp_path): + driver = MagicMock(name="driver") + driver.capabilities = {"browserName": "chrome"} + driver.execute_script.side_effect = lambda script: { + "return document.body.parentNode.scrollWidth": 100, + "return document.body.parentNode.scrollHeight": 150, # 3 tiles of 60 + "return document.documentElement.clientWidth": 100, + "return window.innerHeight": 60, + }.get(script) # window.scrollTo(...) -> None + driver.get_screenshot_as_png.side_effect = [ + png_bytes(100, 60, (255, 0, 0)), # tile @ y=0 + png_bytes(100, 60, (0, 255, 0)), # tile @ y=60 + png_bytes(100, 60, (0, 0, 255)), # tile @ y=120, clamped to paste_y=90 + ] + out = tmp_path / "shot.png" + assert save_full_page(driver, str(out)) is True + img = Image.open(out) + assert img.size == (100, 150) + assert img.getpixel((50, 30)) == (255, 0, 0) # first tile + assert img.getpixel((50, 75)) == (0, 255, 0) # second tile (rows 60-89) + assert img.getpixel((50, 140)) == (0, 0, 255) # third tile, bottom-aligned + + def test_wide_page_clamps_last_column(self, tmp_path): + driver = MagicMock(name="driver") + driver.capabilities = {"browserName": "chrome"} + driver.execute_script.side_effect = lambda script: { + "return document.body.parentNode.scrollWidth": 150, # 2 columns of 100 + "return document.body.parentNode.scrollHeight": 60, + "return document.documentElement.clientWidth": 100, + "return window.innerHeight": 60, + }.get(script) + driver.get_screenshot_as_png.side_effect = [ + png_bytes(100, 60, (255, 0, 0)), # column @ x=0 + png_bytes(100, 60, (0, 0, 255)), # column @ x=100, clamped to paste_x=50 + ] + out = tmp_path / "shot.png" + assert save_full_page(driver, str(out)) is True + img = Image.open(out) + assert img.size == (150, 60) + assert img.getpixel((25, 30)) == (255, 0, 0) # left column (cols 0-49) + assert img.getpixel((140, 30)) == (0, 0, 255) # right column, clamped (cols 50-149) ``` - [ ] **Step 2: Run tests to verify they fail** @@ -2371,12 +2413,14 @@ def _stitch_chromium(driver, filename: str) -> bool: while x < total_width: driver.execute_script(f"window.scrollTo({x}, {y})") time.sleep(_SCROLL_SETTLE_SECONDS) - tile = Image.open(BytesIO(driver.get_screenshot_as_png())) - # the last row/column can't scroll a full viewport: paste aligned - # to the bottom/right edge instead of duplicating content - paste_x = min(x, total_width - viewport_width) - paste_y = min(y, total_height - viewport_height) - stitched.paste(tile, (max(paste_x, 0), max(paste_y, 0))) + # Tiles are device pixels; the canvas/scroll math is CSS pixels. Assumes + # devicePixelRatio == 1 (true for headless chromium in the test target). + with Image.open(BytesIO(driver.get_screenshot_as_png())) as tile: + # the last row/column can't scroll a full viewport: paste aligned + # to the bottom/right edge instead of duplicating content + paste_x = min(x, total_width - viewport_width) + paste_y = min(y, total_height - viewport_height) + stitched.paste(tile, (max(paste_x, 0), max(paste_y, 0))) x += viewport_width y += viewport_height stitched.save(filename) @@ -2387,7 +2431,7 @@ def _stitch_chromium(driver, filename: str) -> bool: - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/unit/test_screenshot.py -v` -Expected: 3 passed. Also run `uv run pytest` — all unit tests still pass. +Expected: 5 passed. Also run `uv run pytest` — all unit tests still pass. - [ ] **Step 5: Commit** @@ -2949,7 +2993,7 @@ Run: `uv run pytest tests/unit/test_api.py -v` Expected: 11 passed Run: `uv run pytest` -Expected: full unit suite passes (126 tests: pacing 7, sessions 14, hosts 19, reattach 6, fields 17, page 36, screenshot 3, browser 13, api 11) +Expected: full unit suite passes (128 tests: pacing 7, sessions 14, hosts 19, reattach 6, fields 17, page 36, screenshot 5, browser 13, api 11) - [ ] **Step 5: Commit** diff --git a/src/wabot/screenshot.py b/src/wabot/screenshot.py index 6dbdff7..2f79ced 100644 --- a/src/wabot/screenshot.py +++ b/src/wabot/screenshot.py @@ -59,12 +59,14 @@ def _stitch_chromium(driver, filename: str) -> bool: while x < total_width: driver.execute_script(f"window.scrollTo({x}, {y})") time.sleep(_SCROLL_SETTLE_SECONDS) - tile = Image.open(BytesIO(driver.get_screenshot_as_png())) - # the last row/column can't scroll a full viewport: paste aligned - # to the bottom/right edge instead of duplicating content - paste_x = min(x, total_width - viewport_width) - paste_y = min(y, total_height - viewport_height) - stitched.paste(tile, (max(paste_x, 0), max(paste_y, 0))) + # Tiles are device pixels; the canvas/scroll math is CSS pixels. Assumes + # devicePixelRatio == 1 (true for headless chromium in the test target). + with Image.open(BytesIO(driver.get_screenshot_as_png())) as tile: + # the last row/column can't scroll a full viewport: paste aligned + # to the bottom/right edge instead of duplicating content + paste_x = min(x, total_width - viewport_width) + paste_y = min(y, total_height - viewport_height) + stitched.paste(tile, (max(paste_x, 0), max(paste_y, 0))) x += viewport_width y += viewport_height stitched.save(filename) diff --git a/tests/unit/test_screenshot.py b/tests/unit/test_screenshot.py index 617f129..126430f 100644 --- a/tests/unit/test_screenshot.py +++ b/tests/unit/test_screenshot.py @@ -66,3 +66,45 @@ class TestChromiumPath: if c.args and str(c.args[0]).startswith("window.scrollTo") ] assert len(scroll_calls) >= 2 + + def test_tall_page_stitches_tiles_bottom_aligned(self, tmp_path): + driver = MagicMock(name="driver") + driver.capabilities = {"browserName": "chrome"} + driver.execute_script.side_effect = lambda script: { + "return document.body.parentNode.scrollWidth": 100, + "return document.body.parentNode.scrollHeight": 150, # 3 tiles of 60 + "return document.documentElement.clientWidth": 100, + "return window.innerHeight": 60, + }.get(script) # window.scrollTo(...) -> None + driver.get_screenshot_as_png.side_effect = [ + png_bytes(100, 60, (255, 0, 0)), # tile @ y=0 + png_bytes(100, 60, (0, 255, 0)), # tile @ y=60 + png_bytes(100, 60, (0, 0, 255)), # tile @ y=120, clamped to paste_y=90 + ] + out = tmp_path / "shot.png" + assert save_full_page(driver, str(out)) is True + img = Image.open(out) + assert img.size == (100, 150) + assert img.getpixel((50, 30)) == (255, 0, 0) # first tile + assert img.getpixel((50, 75)) == (0, 255, 0) # second tile (rows 60-89) + assert img.getpixel((50, 140)) == (0, 0, 255) # third tile, bottom-aligned + + def test_wide_page_clamps_last_column(self, tmp_path): + driver = MagicMock(name="driver") + driver.capabilities = {"browserName": "chrome"} + driver.execute_script.side_effect = lambda script: { + "return document.body.parentNode.scrollWidth": 150, # 2 columns of 100 + "return document.body.parentNode.scrollHeight": 60, + "return document.documentElement.clientWidth": 100, + "return window.innerHeight": 60, + }.get(script) + driver.get_screenshot_as_png.side_effect = [ + png_bytes(100, 60, (255, 0, 0)), # column @ x=0 + png_bytes(100, 60, (0, 0, 255)), # column @ x=100, clamped to paste_x=50 + ] + out = tmp_path / "shot.png" + assert save_full_page(driver, str(out)) is True + img = Image.open(out) + assert img.size == (150, 60) + assert img.getpixel((25, 30)) == (255, 0, 0) # left column (cols 0-49) + assert img.getpixel((140, 30)) == (0, 0, 255) # right column, clamped (cols 50-149) From 47349154758f48dea282483766999e1370a73312 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 23:25:24 -0600 Subject: [PATCH 22/35] feat: Browser facade with working refuse-after-exception guard --- src/wabot/browser.py | 90 ++++++++++++++++++++++++++++++++++ tests/unit/test_browser.py | 99 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 src/wabot/browser.py create mode 100644 tests/unit/test_browser.py diff --git a/src/wabot/browser.py b/src/wabot/browser.py new file mode 100644 index 0000000..17f47aa --- /dev/null +++ b/src/wabot/browser.py @@ -0,0 +1,90 @@ +"""Browser: the facade consumers drive. + +Holds the webdriver plus a *current page* object. Unknown attribute +access is delegated to the current page (``bot.login()`` invokes +``bot.page.login()``); ``bot[key]`` resolves elements on it. Any +``perform()`` failure flips ``good = False``, after which page actions +are refused until ``reset()`` — "log and refuse, don't crash". +""" + +from __future__ import annotations + +import logging + +from selenium.common.exceptions import WebDriverException + +from .pacing import HumanPacing + +LOGGER = logging.getLogger("wabot") + +# attributes that must never be delegated to the page +_OWN_ATTRS = frozenset( + {"driver", "pacing", "session_name", "good", "page", "_store"} +) + + +def _refused(*_args, **_kwargs): + return None + + +class Browser: + def __init__(self, driver, *, pacing=None, session_name=None, store=None): + self.driver = driver + self.pacing = pacing if pacing is not None else HumanPacing() + self.session_name = session_name + self._store = store + self.good = True + self.page = None + + def set_page(self, page_cls) -> bool: + """Instantiate ``page_cls`` and make it current if its verify() passes.""" + page = page_cls(self) + if not page.verify(): + LOGGER.error("failed to verify page: %s", page_cls.__name__) + return False + self.page = page + return True + + def __getattr__(self, name): + if name in _OWN_ATTRS or name.startswith("__"): + raise AttributeError(name) + if self.page is None: + raise AttributeError( + f"{name!r}: no current page — call set_page() first" + ) + if not self.good: + LOGGER.warning("broken state — refusing page action %r (call reset())", name) + return _refused + return getattr(self.page, name) + + def __getitem__(self, key): + return self.page[key] + + def perform(self, method: str, *args, **kwargs): + """Invoke a page method, trapping failures instead of raising.""" + if not self.good: + LOGGER.warning("broken state — refusing %r (call reset())", method) + return None + try: + fn = getattr(self.page, method) + except AttributeError: + LOGGER.error("page %s has no action %r", type(self.page).__name__, method) + return None + try: + return fn(*args, **kwargs) + except WebDriverException: + LOGGER.exception("page action %r failed; flipping good=False", method) + self.good = False + return None + + def reset(self) -> None: + """Clear the broken-state flag after an exception.""" + self.good = True + + def quit(self) -> None: + """Quit the browser; forget the saved session if there is one.""" + try: + self.driver.quit() + finally: + if self._store is not None and self.session_name: + self._store.remove(self.session_name) diff --git a/tests/unit/test_browser.py b/tests/unit/test_browser.py new file mode 100644 index 0000000..31cc48d --- /dev/null +++ b/tests/unit/test_browser.py @@ -0,0 +1,99 @@ +from unittest.mock import MagicMock + +import pytest +from selenium.common.exceptions import WebDriverException + +from wabot.browser import Browser +from wabot.pacing import HumanPacing, NoPacing +from wabot.page import Page + + +class Login(Page): + elements = {"username": ("el", ("id", "username"))} + + def do_login(self): + return "logged-in" + + def explode(self): + raise WebDriverException("browser gone") + + +class Unverifiable(Page): + def verify(self): + return False + + +@pytest.fixture +def bot(): + return Browser(MagicMock(name="driver"), pacing=NoPacing()) + + +class TestConstruction: + def test_good_flag_starts_true(self, bot): + assert bot.good is True + + def test_default_pacing_is_human(self): + assert isinstance(Browser(MagicMock()).pacing, HumanPacing) + + +class TestSetPage: + def test_sets_and_returns_true(self, bot): + assert bot.set_page(Login) is True + assert isinstance(bot.page, Login) + + def test_failed_verify_refuses_switch(self, bot): + bot.set_page(Login) + assert bot.set_page(Unverifiable) is False + assert isinstance(bot.page, Login) # unchanged + + +class TestDelegation: + def test_getattr_delegates_to_page(self, bot): + bot.set_page(Login) + assert bot.do_login() == "logged-in" + + def test_getitem_delegates_to_page(self, bot): + bot.set_page(Login) + el = bot["username"] + assert el.name == "username" + + def test_getattr_without_page_raises(self, bot): + with pytest.raises(AttributeError): + _ = bot.do_login + + +class TestPerform: + def test_returns_method_result(self, bot): + bot.set_page(Login) + assert bot.perform("do_login") == "logged-in" + + def test_missing_method_returns_none(self, bot): + bot.set_page(Login) + assert bot.perform("nope") is None + + def test_webdriver_exception_flips_good(self, bot): + bot.set_page(Login) + assert bot.perform("explode") is None + assert bot.good is False + + def test_broken_state_refuses_actions_until_reset(self, bot): + bot.set_page(Login) + bot.perform("explode") + assert bot.perform("do_login") is None # refused + assert bot.do_login() is None # __getattr__ path also refused + bot.reset() + assert bot.perform("do_login") == "logged-in" + + +class TestQuit: + def test_quit_quits_driver_and_removes_session(self): + driver, store = MagicMock(), MagicMock() + bot = Browser(driver, session_name="s1", store=store) + bot.quit() + driver.quit.assert_called_once_with() + store.remove.assert_called_once_with("s1") + + def test_quit_without_session_only_quits(self): + driver = MagicMock() + Browser(driver).quit() + driver.quit.assert_called_once_with() From 3d0e00e88f3ee2d46890e4020da11e0576d13332 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Wed, 8 Jul 2026 22:08:56 -0600 Subject: [PATCH 23/35] fix: Browser.quit() stops managed driver service; document facade semantics --- .../plans/2026-07-07-wabot-modernization.md | 97 +++++++++++++++++-- src/wabot/browser.py | 42 +++++++- tests/unit/test_browser.py | 49 ++++++++++ 3 files changed, 179 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-07-07-wabot-modernization.md b/docs/superpowers/plans/2026-07-07-wabot-modernization.md index 8a5a3cc..6c37d8c 100644 --- a/docs/superpowers/plans/2026-07-07-wabot-modernization.md +++ b/docs/superpowers/plans/2026-07-07-wabot-modernization.md @@ -2455,6 +2455,7 @@ Naming note: the spec says "`reset_good_status()` retained"; under the approved `tests/unit/test_browser.py`: ```python +from types import SimpleNamespace from unittest.mock import MagicMock import pytest @@ -2516,7 +2517,23 @@ class TestDelegation: def test_getattr_without_page_raises(self, bot): with pytest.raises(AttributeError): - bot.do_login + _ = bot.do_login + + def test_direct_delegation_does_not_flip_good(self, bot): + # only perform() traps+flips; direct bot.x() is the raw path + bot.set_page(Login) + with pytest.raises(WebDriverException): + bot.explode() + assert bot.good is True + + def test_getitem_without_page_raises_friendly(self, bot): + with pytest.raises(RuntimeError, match="no current page"): + bot["username"] + + def test_repr_shows_page_and_good(self, bot): + assert "page=None" in repr(bot) and "good=True" in repr(bot) + bot.set_page(Login) + assert "page=Login" in repr(bot) class TestPerform: @@ -2541,10 +2558,18 @@ class TestPerform: bot.reset() assert bot.perform("do_login") == "logged-in" + def test_broken_state_attribute_read_returns_noop_callable(self, bot): + # documents the footgun: while broken, an attribute read yields the + # no-op refusal callable, not the underlying value + bot.set_page(Login) + bot.perform("explode") # flips good=False + assert callable(bot.anything) + class TestQuit: def test_quit_quits_driver_and_removes_session(self): driver, store = MagicMock(), MagicMock() + store.get.return_value = SimpleNamespace(service_pid=None) # external: nothing to stop bot = Browser(driver, session_name="s1", store=store) bot.quit() driver.quit.assert_called_once_with() @@ -2554,6 +2579,30 @@ class TestQuit: driver = MagicMock() Browser(driver).quit() driver.quit.assert_called_once_with() + + def test_quit_stops_managed_service(self, monkeypatch): + import wabot.browser as browser_mod + + stopped = [] + monkeypatch.setattr(browser_mod, "stop_service", lambda pid: stopped.append(pid)) + driver, store = MagicMock(), MagicMock() + store.get.return_value = SimpleNamespace(service_pid=4321) # managed session + bot = Browser(driver, session_name="s1", store=store) + bot.quit() + assert stopped == [4321] + 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 + + monkeypatch.setattr(browser_mod, "stop_service", lambda pid: None) + driver, store = MagicMock(), MagicMock() + driver.quit.side_effect = WebDriverException("boom") + store.get.return_value = SimpleNamespace(service_pid=None) + bot = Browser(driver, session_name="s1", store=store) + with pytest.raises(WebDriverException): + bot.quit() + store.remove.assert_called_once_with("s1") # finally block still ran ``` - [ ] **Step 2: Run tests to verify they fail** @@ -2573,6 +2622,18 @@ access is delegated to the current page (``bot.login()`` invokes ``bot.page.login()``); ``bot[key]`` resolves elements on it. Any ``perform()`` failure flips ``good = False``, after which page actions are refused until ``reset()`` — "log and refuse, don't crash". + + Two ways to drive the current page: + + * Direct delegation — ``bot.login()`` calls ``bot.page.login()``. This is + the raw path: it raises on error and does NOT touch ``good``. + * ``bot.perform("login")`` — traps driver failures, flips ``good`` to + False on error, and refuses further page actions until ``reset()``. + + Route anything that can fail through ``perform()`` for the guard. While + ``good`` is False, refused delegation returns a no-op callable, so + ``bot.x()`` yields None; reading a page *attribute* in that state is not + meaningful. """ from __future__ import annotations @@ -2581,6 +2642,7 @@ import logging from selenium.common.exceptions import WebDriverException +from .hosts import stop_service from .pacing import HumanPacing LOGGER = logging.getLogger("wabot") @@ -2604,8 +2666,15 @@ class Browser: self.good = True self.page = None + def __repr__(self) -> str: + page = type(self.page).__name__ if self.page is not None else None + return f"" + def set_page(self, page_cls) -> bool: - """Instantiate ``page_cls`` and make it current if its verify() passes.""" + """Instantiate ``page_cls`` and make it current if its verify() passes. + + A consumer ``verify()`` that raises propagates (it is not trapped). + """ page = page_cls(self) if not page.verify(): LOGGER.error("failed to verify page: %s", page_cls.__name__) @@ -2626,10 +2695,14 @@ class Browser: return getattr(self.page, name) def __getitem__(self, key): + if self.page is None: + raise RuntimeError( + f"cannot resolve element {key!r}: no current page — call set_page() first" + ) return self.page[key] def perform(self, method: str, *args, **kwargs): - """Invoke a page method, trapping failures instead of raising.""" + """Invoke a page method, trapping driver failures instead of raising.""" if not self.good: LOGGER.warning("broken state — refusing %r (call reset())", method) return None @@ -2650,10 +2723,22 @@ class Browser: self.good = True def quit(self) -> None: - """Quit the browser; forget the saved session if there is one.""" + """Quit the browser and forget the saved session. + + For a wabot-managed driver service, its detached process is also + stopped — a plain ``driver.quit()`` leaves that service running. + External servers (records without a ``service_pid``) are left alone. + Use this for full teardown; to keep a session alive for another + process, let this process exit WITHOUT calling ``quit()``. + """ + record = None + if self._store is not None and self.session_name: + record = self._store.get(self.session_name) try: self.driver.quit() finally: + if record is not None and record.service_pid: + stop_service(record.service_pid) if self._store is not None and self.session_name: self._store.remove(self.session_name) ``` @@ -2661,7 +2746,7 @@ class Browser: - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/unit/test_browser.py -v` -Expected: 13 passed +Expected: 19 passed - [ ] **Step 5: Commit** @@ -2993,7 +3078,7 @@ Run: `uv run pytest tests/unit/test_api.py -v` Expected: 11 passed Run: `uv run pytest` -Expected: full unit suite passes (128 tests: pacing 7, sessions 14, hosts 19, reattach 6, fields 17, page 36, screenshot 5, browser 13, api 11) +Expected: full unit suite passes (134 tests: pacing 7, sessions 14, hosts 19, reattach 6, fields 17, page 36, screenshot 5, browser 19, api 11) - [ ] **Step 5: Commit** diff --git a/src/wabot/browser.py b/src/wabot/browser.py index 17f47aa..8a62c0a 100644 --- a/src/wabot/browser.py +++ b/src/wabot/browser.py @@ -5,6 +5,18 @@ access is delegated to the current page (``bot.login()`` invokes ``bot.page.login()``); ``bot[key]`` resolves elements on it. Any ``perform()`` failure flips ``good = False``, after which page actions are refused until ``reset()`` — "log and refuse, don't crash". + + Two ways to drive the current page: + + * Direct delegation — ``bot.login()`` calls ``bot.page.login()``. This is + the raw path: it raises on error and does NOT touch ``good``. + * ``bot.perform("login")`` — traps driver failures, flips ``good`` to + False on error, and refuses further page actions until ``reset()``. + + Route anything that can fail through ``perform()`` for the guard. While + ``good`` is False, refused delegation returns a no-op callable, so + ``bot.x()`` yields None; reading a page *attribute* in that state is not + meaningful. """ from __future__ import annotations @@ -13,6 +25,7 @@ import logging from selenium.common.exceptions import WebDriverException +from .hosts import stop_service from .pacing import HumanPacing LOGGER = logging.getLogger("wabot") @@ -36,8 +49,15 @@ class Browser: self.good = True self.page = None + def __repr__(self) -> str: + page = type(self.page).__name__ if self.page is not None else None + return f"" + def set_page(self, page_cls) -> bool: - """Instantiate ``page_cls`` and make it current if its verify() passes.""" + """Instantiate ``page_cls`` and make it current if its verify() passes. + + A consumer ``verify()`` that raises propagates (it is not trapped). + """ page = page_cls(self) if not page.verify(): LOGGER.error("failed to verify page: %s", page_cls.__name__) @@ -58,10 +78,14 @@ class Browser: return getattr(self.page, name) def __getitem__(self, key): + if self.page is None: + raise RuntimeError( + f"cannot resolve element {key!r}: no current page — call set_page() first" + ) return self.page[key] def perform(self, method: str, *args, **kwargs): - """Invoke a page method, trapping failures instead of raising.""" + """Invoke a page method, trapping driver failures instead of raising.""" if not self.good: LOGGER.warning("broken state — refusing %r (call reset())", method) return None @@ -82,9 +106,21 @@ class Browser: self.good = True def quit(self) -> None: - """Quit the browser; forget the saved session if there is one.""" + """Quit the browser and forget the saved session. + + For a wabot-managed driver service, its detached process is also + stopped — a plain ``driver.quit()`` leaves that service running. + External servers (records without a ``service_pid``) are left alone. + Use this for full teardown; to keep a session alive for another + process, let this process exit WITHOUT calling ``quit()``. + """ + record = None + if self._store is not None and self.session_name: + record = self._store.get(self.session_name) try: self.driver.quit() finally: + if record is not None and record.service_pid: + stop_service(record.service_pid) if self._store is not None and self.session_name: self._store.remove(self.session_name) diff --git a/tests/unit/test_browser.py b/tests/unit/test_browser.py index 31cc48d..7b71428 100644 --- a/tests/unit/test_browser.py +++ b/tests/unit/test_browser.py @@ -1,3 +1,4 @@ +from types import SimpleNamespace from unittest.mock import MagicMock import pytest @@ -61,6 +62,22 @@ class TestDelegation: with pytest.raises(AttributeError): _ = bot.do_login + def test_direct_delegation_does_not_flip_good(self, bot): + # only perform() traps+flips; direct bot.x() is the raw path + bot.set_page(Login) + with pytest.raises(WebDriverException): + bot.explode() + assert bot.good is True + + def test_getitem_without_page_raises_friendly(self, bot): + with pytest.raises(RuntimeError, match="no current page"): + bot["username"] + + def test_repr_shows_page_and_good(self, bot): + assert "page=None" in repr(bot) and "good=True" in repr(bot) + bot.set_page(Login) + assert "page=Login" in repr(bot) + class TestPerform: def test_returns_method_result(self, bot): @@ -84,10 +101,18 @@ class TestPerform: bot.reset() assert bot.perform("do_login") == "logged-in" + def test_broken_state_attribute_read_returns_noop_callable(self, bot): + # documents the footgun: while broken, an attribute read yields the + # no-op refusal callable, not the underlying value + bot.set_page(Login) + bot.perform("explode") # flips good=False + assert callable(bot.anything) + class TestQuit: def test_quit_quits_driver_and_removes_session(self): driver, store = MagicMock(), MagicMock() + store.get.return_value = SimpleNamespace(service_pid=None) # external: nothing to stop bot = Browser(driver, session_name="s1", store=store) bot.quit() driver.quit.assert_called_once_with() @@ -97,3 +122,27 @@ class TestQuit: driver = MagicMock() Browser(driver).quit() driver.quit.assert_called_once_with() + + def test_quit_stops_managed_service(self, monkeypatch): + import wabot.browser as browser_mod + + stopped = [] + monkeypatch.setattr(browser_mod, "stop_service", lambda pid: stopped.append(pid)) + driver, store = MagicMock(), MagicMock() + store.get.return_value = SimpleNamespace(service_pid=4321) # managed session + bot = Browser(driver, session_name="s1", store=store) + bot.quit() + assert stopped == [4321] + 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 + + monkeypatch.setattr(browser_mod, "stop_service", lambda pid: None) + driver, store = MagicMock(), MagicMock() + driver.quit.side_effect = WebDriverException("boom") + store.get.return_value = SimpleNamespace(service_pid=None) + bot = Browser(driver, session_name="s1", store=store) + with pytest.raises(WebDriverException): + bot.quit() + store.remove.assert_called_once_with("s1") # finally block still ran From 5245757cc809b6ce376962217d04d4aa828df1ea Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Wed, 8 Jul 2026 22:13:01 -0600 Subject: [PATCH 24/35] =?UTF-8?q?feat:=20public=20API=20=E2=80=94=20wabot.?= =?UTF-8?q?browser()/sessions()/destroy()=20with=20reattach=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/wabot/__init__.py | 148 ++++++++++++++++++++++++++++++++++++- tests/unit/test_api.py | 143 +++++++++++++++++++++++++++++++++++ tests/unit/test_browser.py | 15 +++- 3 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_api.py diff --git a/src/wabot/__init__.py b/src/wabot/__init__.py index cdc5338..49b51d9 100644 --- a/src/wabot/__init__.py +++ b/src/wabot/__init__.py @@ -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 diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py new file mode 100644 index 0000000..fd87afc --- /dev/null +++ b/tests/unit/test_api.py @@ -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 diff --git a/tests/unit/test_browser.py b/tests/unit/test_browser.py index 7b71428..eae72e9 100644 --- a/tests/unit/test_browser.py +++ b/tests/unit/test_browser.py @@ -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() From b20cf0d10b3e0134197cb8cc31469f33e3980378 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Wed, 8 Jul 2026 22:28:09 -0600 Subject: [PATCH 25/35] fix: browser()/destroy() never leak a managed driver service --- .../plans/2026-07-07-wabot-modernization.md | 142 +++++++++++++++--- src/wabot/__init__.py | 52 +++++-- tests/unit/test_api.py | 86 +++++++++++ 3 files changed, 246 insertions(+), 34 deletions(-) diff --git a/docs/superpowers/plans/2026-07-07-wabot-modernization.md b/docs/superpowers/plans/2026-07-07-wabot-modernization.md index 6c37d8c..66def02 100644 --- a/docs/superpowers/plans/2026-07-07-wabot-modernization.md +++ b/docs/superpowers/plans/2026-07-07-wabot-modernization.md @@ -2907,6 +2907,92 @@ class TestHousekeeping: assert wabot.destroy("nope", store=store) is False +class TestResourceCleanup: + def test_managed_service_stopped_when_driver_creation_fails( + self, fake_selenium, store, monkeypatch + ): + from selenium.common.exceptions import WebDriverException + + stopped = [] + monkeypatch.setattr(wabot, "stop_service", lambda pid: stopped.append(pid)) + fake_selenium.Remote.side_effect = WebDriverException("startup failed") + with pytest.raises(WebDriverException): + wabot.browser(session="s1", browser="chromium", store=store) + assert stopped == [4321] # spawned managed service was stopped + assert store.get("s1") is None # nothing persisted + + def test_dead_managed_session_stops_old_service_before_recreating( + self, fake_selenium, store, monkeypatch + ): + from datetime import datetime, timezone + + stopped = [] + monkeypatch.setattr(wabot, "stop_service", lambda pid: stopped.append(pid)) + store.save(wabot.SessionRecord( + name="s1", executor_url="http://127.0.0.1:7777", session_id="old", + browser="chromium", created_at=datetime.now(timezone.utc).isoformat(), + service_pid=9999, service_port=7777, + )) + fake_selenium.service_alive.return_value = True + fake_selenium.attach.return_value = None # session dead on a live server + wabot.browser(session="s1", store=store) + assert 9999 in stopped # old managed service stopped, not leaked + fake_selenium.Remote.assert_called_once() # fresh browser created + assert store.get("s1").session_id == "new-session-id" + + +class TestDestroyEdges: + def _save(self, store, **kw): + from datetime import datetime, timezone + + defaults = dict( + name="s1", executor_url="http://127.0.0.1:7777", session_id="old", + browser="chromium", created_at=datetime.now(timezone.utc).isoformat(), + ) + defaults.update(kw) + store.save(wabot.SessionRecord(**defaults)) + + def test_destroy_external_session_does_not_stop_service( + self, fake_selenium, store, monkeypatch + ): + stopped = [] + monkeypatch.setattr(wabot, "stop_service", lambda pid: stopped.append(pid)) + self._save(store, name="ext", executor_url="http://grid:4444", service_pid=None) + fake_selenium.service_alive.return_value = True + assert wabot.destroy("ext", store=store) is True + assert stopped == [] # external server: not ours to stop + assert store.get("ext") is None + + def test_destroy_dead_session_still_stops_and_removes(self, fake_selenium, store, monkeypatch): + stopped = [] + monkeypatch.setattr(wabot, "stop_service", lambda pid: stopped.append(pid)) + self._save(store, service_pid=4321) + fake_selenium.service_alive.return_value = True + fake_selenium.attach.return_value = None # dead session + assert wabot.destroy("s1", store=store) is True + assert stopped == [4321] # service stopped despite dead session + assert store.get("s1") is None + + +class TestPlumbing: + def test_headless_and_user_agent_reach_build_options(self, fake_selenium, store, monkeypatch): + captured = {} + real_build = wabot.build_options + + def spy(browser, **kwargs): + captured.update(kwargs) + return real_build(browser, **kwargs) + + monkeypatch.setattr(wabot, "build_options", spy) + wabot.browser(browser="chromium", headless=True, user_agent="Bot/1.0", store=store) + assert captured == {"headless": True, "user_agent": "Bot/1.0"} + + def test_pacing_reaches_browser(self, fake_selenium, store): + pacing = wabot.NoPacing() + bot = wabot.browser(browser="chromium", pacing=pacing, store=store) + assert bot.pacing is pacing + + class TestPublicSurface: def test_all_exports_exist(self): for name in wabot.__all__: @@ -2936,6 +3022,7 @@ Quickstart:: from __future__ import annotations +import contextlib import logging from datetime import datetime, timezone @@ -2999,6 +3086,9 @@ def browser( ) -> Browser: """Create a Browser, reattaching to a saved session when one exists. + When reattaching to an existing session, ``headless`` and ``user_agent`` + are ignored (the browser already exists). + Args: session: Persistence name. None (default) = ephemeral: the browser is not saved and (without ``host``) dies with this process. @@ -3028,22 +3118,36 @@ def browser( 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) + if record.service_pid: + stop_service(record.service_pid) # don't leak the old managed service 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), + driver = None + try: + 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), + ) ) - ) + except BaseException: + # startup or persistence failed: don't leak the driver session or the + # detached service we just spawned + if driver is not None: + with contextlib.suppress(Exception): + driver.quit() + pid = getattr(host_obj, "pid", None) + if pid: + stop_service(pid) + raise LOGGER.info("created persistent session %r on %s", session, url) return Browser(driver, pacing=pacing, session_name=session, store=store) @@ -3060,12 +3164,14 @@ def destroy(name: str, store: SessionStore | None = None) -> bool: 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: + try: + driver = attach(record.executor_url, record.session_id, record.browser) + if driver is not None: driver.quit() - except WebDriverException as ex: - LOGGER.warning("quit failed while destroying %r: %s", name, ex) + except WebDriverException as ex: + LOGGER.warning("quit failed while destroying %r: %s", name, ex) + except RuntimeError as ex: # attach adoption guard can raise + LOGGER.warning("could not reattach to destroy %r: %s", name, ex) if record.service_pid: stop_service(record.service_pid) store.remove(name) @@ -3075,10 +3181,10 @@ def destroy(name: str, store: SessionStore | None = None) -> bool: - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest tests/unit/test_api.py -v` -Expected: 11 passed +Expected: 17 passed Run: `uv run pytest` -Expected: full unit suite passes (134 tests: pacing 7, sessions 14, hosts 19, reattach 6, fields 17, page 36, screenshot 5, browser 19, api 11) +Expected: full unit suite passes (140 tests: pacing 7, sessions 14, hosts 19, reattach 6, fields 17, page 36, screenshot 5, browser 19, api 17) - [ ] **Step 5: Commit** diff --git a/src/wabot/__init__.py b/src/wabot/__init__.py index 49b51d9..2d53a7d 100644 --- a/src/wabot/__init__.py +++ b/src/wabot/__init__.py @@ -11,6 +11,7 @@ Quickstart:: from __future__ import annotations +import contextlib import logging from datetime import datetime, timezone @@ -74,6 +75,9 @@ def browser( ) -> Browser: """Create a Browser, reattaching to a saved session when one exists. + When reattaching to an existing session, ``headless`` and ``user_agent`` + are ignored (the browser already exists). + Args: session: Persistence name. None (default) = ephemeral: the browser is not saved and (without ``host``) dies with this process. @@ -103,22 +107,36 @@ def browser( 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) + if record.service_pid: + stop_service(record.service_pid) # don't leak the old managed service 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), + driver = None + try: + 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), + ) ) - ) + except BaseException: + # startup or persistence failed: don't leak the driver session or the + # detached service we just spawned + if driver is not None: + with contextlib.suppress(Exception): + driver.quit() + pid = getattr(host_obj, "pid", None) + if pid: + stop_service(pid) + raise LOGGER.info("created persistent session %r on %s", session, url) return Browser(driver, pacing=pacing, session_name=session, store=store) @@ -135,12 +153,14 @@ def destroy(name: str, store: SessionStore | None = None) -> bool: 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: + try: + driver = attach(record.executor_url, record.session_id, record.browser) + if driver is not None: driver.quit() - except WebDriverException as ex: - LOGGER.warning("quit failed while destroying %r: %s", name, ex) + except WebDriverException as ex: + LOGGER.warning("quit failed while destroying %r: %s", name, ex) + except RuntimeError as ex: # attach adoption guard can raise + LOGGER.warning("could not reattach to destroy %r: %s", name, ex) if record.service_pid: stop_service(record.service_pid) store.remove(name) diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index fd87afc..186a58b 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -137,6 +137,92 @@ class TestHousekeeping: assert wabot.destroy("nope", store=store) is False +class TestResourceCleanup: + def test_managed_service_stopped_when_driver_creation_fails( + self, fake_selenium, store, monkeypatch + ): + from selenium.common.exceptions import WebDriverException + + stopped = [] + monkeypatch.setattr(wabot, "stop_service", lambda pid: stopped.append(pid)) + fake_selenium.Remote.side_effect = WebDriverException("startup failed") + with pytest.raises(WebDriverException): + wabot.browser(session="s1", browser="chromium", store=store) + assert stopped == [4321] # spawned managed service was stopped + assert store.get("s1") is None # nothing persisted + + def test_dead_managed_session_stops_old_service_before_recreating( + self, fake_selenium, store, monkeypatch + ): + from datetime import datetime, timezone + + stopped = [] + monkeypatch.setattr(wabot, "stop_service", lambda pid: stopped.append(pid)) + store.save(wabot.SessionRecord( + name="s1", executor_url="http://127.0.0.1:7777", session_id="old", + browser="chromium", created_at=datetime.now(timezone.utc).isoformat(), + service_pid=9999, service_port=7777, + )) + fake_selenium.service_alive.return_value = True + fake_selenium.attach.return_value = None # session dead on a live server + wabot.browser(session="s1", store=store) + assert 9999 in stopped # old managed service stopped, not leaked + fake_selenium.Remote.assert_called_once() # fresh browser created + assert store.get("s1").session_id == "new-session-id" + + +class TestDestroyEdges: + def _save(self, store, **kw): + from datetime import datetime, timezone + + defaults = dict( + name="s1", executor_url="http://127.0.0.1:7777", session_id="old", + browser="chromium", created_at=datetime.now(timezone.utc).isoformat(), + ) + defaults.update(kw) + store.save(wabot.SessionRecord(**defaults)) + + def test_destroy_external_session_does_not_stop_service( + self, fake_selenium, store, monkeypatch + ): + stopped = [] + monkeypatch.setattr(wabot, "stop_service", lambda pid: stopped.append(pid)) + self._save(store, name="ext", executor_url="http://grid:4444", service_pid=None) + fake_selenium.service_alive.return_value = True + assert wabot.destroy("ext", store=store) is True + assert stopped == [] # external server: not ours to stop + assert store.get("ext") is None + + def test_destroy_dead_session_still_stops_and_removes(self, fake_selenium, store, monkeypatch): + stopped = [] + monkeypatch.setattr(wabot, "stop_service", lambda pid: stopped.append(pid)) + self._save(store, service_pid=4321) + fake_selenium.service_alive.return_value = True + fake_selenium.attach.return_value = None # dead session + assert wabot.destroy("s1", store=store) is True + assert stopped == [4321] # service stopped despite dead session + assert store.get("s1") is None + + +class TestPlumbing: + def test_headless_and_user_agent_reach_build_options(self, fake_selenium, store, monkeypatch): + captured = {} + real_build = wabot.build_options + + def spy(browser, **kwargs): + captured.update(kwargs) + return real_build(browser, **kwargs) + + monkeypatch.setattr(wabot, "build_options", spy) + wabot.browser(browser="chromium", headless=True, user_agent="Bot/1.0", store=store) + assert captured == {"headless": True, "user_agent": "Bot/1.0"} + + def test_pacing_reaches_browser(self, fake_selenium, store): + pacing = wabot.NoPacing() + bot = wabot.browser(browser="chromium", pacing=pacing, store=store) + assert bot.pacing is pacing + + class TestPublicSurface: def test_all_exports_exist(self): for name in wabot.__all__: From 8ed9565c88f7a93168a365a6843d0479e366b147 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Wed, 8 Jul 2026 22:31:46 -0600 Subject: [PATCH 26/35] refactor: rename browser submodule to _browser to avoid shadowing browser() --- .../plans/2026-07-07-wabot-modernization.md | 20 +++++++++---------- src/wabot/__init__.py | 2 +- src/wabot/{browser.py => _browser.py} | 0 tests/unit/test_browser.py | 17 +++------------- 4 files changed, 14 insertions(+), 25 deletions(-) rename src/wabot/{browser.py => _browser.py} (100%) diff --git a/docs/superpowers/plans/2026-07-07-wabot-modernization.md b/docs/superpowers/plans/2026-07-07-wabot-modernization.md index 66def02..9b58327 100644 --- a/docs/superpowers/plans/2026-07-07-wabot-modernization.md +++ b/docs/superpowers/plans/2026-07-07-wabot-modernization.md @@ -50,7 +50,7 @@ wabot/ (repo root, branch feature/modernization │ │ # ReattachingRemote, attach │ ├── fields.py # PageObject, TextField, SelectField, CheckField, NullField │ ├── page.py # Page (element maps, click, forms, alerts, waits) -│ ├── browser.py # Browser facade +│ ├── _browser.py # Browser facade (underscored to avoid shadowing browser()) │ └── screenshot.py # save_full_page (firefox raw cmd / chromium stitch) ├── tests/ │ ├── unit/ # test_pacing.py, test_sessions.py, test_hosts.py, @@ -2442,10 +2442,10 @@ git commit -m "feat: full-page screenshots (firefox raw command, chromium PIL st --- -### Task 11: `browser.py` — the Browser facade +### Task 11: `_browser.py` — the Browser facade **Files:** -- Create: `src/wabot/browser.py` +- Create: `src/wabot/_browser.py` - Test: `tests/unit/test_browser.py` Naming note: the spec says "`reset_good_status()` retained"; under the approved free-API-redesign decision it is renamed to the cleaner `reset()` (same semantics: clears the broken-state flag). This is the only intentional naming deviation from the spec. @@ -2461,7 +2461,7 @@ from unittest.mock import MagicMock import pytest from selenium.common.exceptions import WebDriverException -from wabot.browser import Browser +from wabot._browser import Browser from wabot.pacing import HumanPacing, NoPacing from wabot.page import Page @@ -2581,7 +2581,7 @@ class TestQuit: driver.quit.assert_called_once_with() def test_quit_stops_managed_service(self, monkeypatch): - import wabot.browser as browser_mod + import wabot._browser as browser_mod stopped = [] monkeypatch.setattr(browser_mod, "stop_service", lambda pid: stopped.append(pid)) @@ -2593,7 +2593,7 @@ 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 wabot._browser as browser_mod monkeypatch.setattr(browser_mod, "stop_service", lambda pid: None) driver, store = MagicMock(), MagicMock() @@ -2612,7 +2612,7 @@ Expected: FAIL — `ModuleNotFoundError: No module named 'wabot.browser'` - [ ] **Step 3: Write the implementation** -`src/wabot/browser.py`: +`src/wabot/_browser.py`: ```python """Browser: the facade consumers drive. @@ -2751,7 +2751,7 @@ Expected: 19 passed - [ ] **Step 5: Commit** ```bash -git add src/wabot/browser.py tests/unit/test_browser.py +git add src/wabot/_browser.py tests/unit/test_browser.py git commit -m "feat: Browser facade with working refuse-after-exception guard" ``` @@ -3029,7 +3029,7 @@ from datetime import datetime, timezone from selenium import webdriver from selenium.common.exceptions import WebDriverException -from .browser import Browser +from ._browser import Browser from .fields import CheckField, NullField, PageObject, SelectField, TextField from .hosts import ( ExternalServer, @@ -3878,7 +3878,7 @@ Tooling: uv (`uv sync`), pytest (`uv run pytest`; real-browser suite via Selenium 4 (pinned <5: session reattach relies on stable 4.x internals). ``` -In the Architecture section: `BrowserProxy` → `Browser` (`src/wabot/browser.py`), add `pacing.py` (HumanPacing/NoPacing policies), and replace the dill/pickle description with: sessions persist as JSON records `(executor_url, session_id)` in `platformdirs.user_data_dir("wabot")/sessions.json`; `hosts.py` spawns detached driver services (`ManagedService`) or connects to external ones (`ExternalServer`); `ReattachingRemote.start_session` adopts the saved session id. Delete the Gotchas bullets about `LOGGER.trace`, undeclared deps, and star exports (all fixed); keep the Gotcha that external hosts must outlive Python and add: geckodriver reports `ready:false` while its single session is active, so liveness checks ignore the `ready` flag. +In the Architecture section: `BrowserProxy` → `Browser` (`src/wabot/_browser.py`), add `pacing.py` (HumanPacing/NoPacing policies), and replace the dill/pickle description with: sessions persist as JSON records `(executor_url, session_id)` in `platformdirs.user_data_dir("wabot")/sessions.json`; `hosts.py` spawns detached driver services (`ManagedService`) or connects to external ones (`ExternalServer`); `ReattachingRemote.start_session` adopts the saved session id. Delete the Gotchas bullets about `LOGGER.trace`, undeclared deps, and star exports (all fixed); keep the Gotcha that external hosts must outlive Python and add: geckodriver reports `ready:false` while its single session is active, so liveness checks ignore the `ready` flag. - [ ] **Step 3: Full verification** diff --git a/src/wabot/__init__.py b/src/wabot/__init__.py index 2d53a7d..9aa32b1 100644 --- a/src/wabot/__init__.py +++ b/src/wabot/__init__.py @@ -18,7 +18,7 @@ from datetime import datetime, timezone from selenium import webdriver from selenium.common.exceptions import WebDriverException -from .browser import Browser +from ._browser import Browser from .fields import CheckField, NullField, PageObject, SelectField, TextField from .hosts import ( ExternalServer, diff --git a/src/wabot/browser.py b/src/wabot/_browser.py similarity index 100% rename from src/wabot/browser.py rename to src/wabot/_browser.py diff --git a/tests/unit/test_browser.py b/tests/unit/test_browser.py index eae72e9..c6cab80 100644 --- a/tests/unit/test_browser.py +++ b/tests/unit/test_browser.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock import pytest from selenium.common.exceptions import WebDriverException -from wabot.browser import Browser +from wabot._browser import Browser from wabot.pacing import HumanPacing, NoPacing from wabot.page import Page @@ -124,15 +124,7 @@ class TestQuit: driver.quit.assert_called_once_with() def test_quit_stops_managed_service(self, monkeypatch): - 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"] + import wabot._browser as browser_mod stopped = [] monkeypatch.setattr(browser_mod, "stop_service", lambda pid: stopped.append(pid)) @@ -144,10 +136,7 @@ class TestQuit: store.remove.assert_called_once_with("s1") def test_quit_removes_record_even_if_driver_quit_raises(self, monkeypatch): - import sys - - # see NOTE in test_quit_stops_managed_service above - browser_mod = sys.modules["wabot.browser"] + import wabot._browser as browser_mod monkeypatch.setattr(browser_mod, "stop_service", lambda pid: None) driver, store = MagicMock(), MagicMock() From 8682ec28fecbe671e14eb59345fab80976936ba0 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Wed, 8 Jul 2026 22:35:14 -0600 Subject: [PATCH 27/35] =?UTF-8?q?test:=20integration=20fixtures=20?= =?UTF-8?q?=E2=80=94=20static=20site=20server,=20auto-marking=20conftest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/fixtures/login.html | 17 ++++++++++++++ tests/fixtures/welcome.html | 8 +++++++ tests/integration/conftest.py | 43 +++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 tests/fixtures/login.html create mode 100644 tests/fixtures/welcome.html create mode 100644 tests/integration/conftest.py diff --git a/tests/fixtures/login.html b/tests/fixtures/login.html new file mode 100644 index 0000000..fc271be --- /dev/null +++ b/tests/fixtures/login.html @@ -0,0 +1,17 @@ + + +Login + +

Test Login

+
+ + + + +
+ + diff --git a/tests/fixtures/welcome.html b/tests/fixtures/welcome.html new file mode 100644 index 0000000..b68c661 --- /dev/null +++ b/tests/fixtures/welcome.html @@ -0,0 +1,8 @@ + + +Welcome + +

Welcome!

+

You made it.

+ + diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..abfa883 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,43 @@ +"""Shared fixtures for real-browser integration tests. + +Every test in this directory is marked ``integration`` automatically and +runs with NoPacing (human delays would make the suite glacial). Browsers +run headless against static HTML served from tests/fixtures. +""" + +import functools +import http.server +import threading +from pathlib import Path + +import pytest + +FIXTURES = Path(__file__).parent.parent / "fixtures" +BROWSERS = ["firefox", "chromium"] + + +def pytest_collection_modifyitems(items): + integration_dir = Path(__file__).parent + for item in items: + if integration_dir in item.path.parents: + item.add_marker(pytest.mark.integration) + + +@pytest.fixture(scope="session") +def site_url(): + """Serve tests/fixtures over HTTP on an ephemeral port.""" + handler = functools.partial( + http.server.SimpleHTTPRequestHandler, directory=str(FIXTURES) + ) + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f"http://127.0.0.1:{server.server_address[1]}" + server.shutdown() + + +@pytest.fixture +def store(tmp_path): + from wabot.sessions import SessionStore + + return SessionStore(path=tmp_path / "sessions.json") From a83a95c4e815ac24c06115a2e6f09b49a8b5cea2 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Wed, 8 Jul 2026 22:37:37 -0600 Subject: [PATCH 28/35] docs: sync Task 13 conftest with the dir-scoped marker fix --- docs/superpowers/plans/2026-07-07-wabot-modernization.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-07-07-wabot-modernization.md b/docs/superpowers/plans/2026-07-07-wabot-modernization.md index 9b58327..1ba359d 100644 --- a/docs/superpowers/plans/2026-07-07-wabot-modernization.md +++ b/docs/superpowers/plans/2026-07-07-wabot-modernization.md @@ -3261,8 +3261,10 @@ BROWSERS = ["firefox", "chromium"] def pytest_collection_modifyitems(items): + integration_dir = Path(__file__).parent for item in items: - item.add_marker(pytest.mark.integration) + if integration_dir in item.path.parents: + item.add_marker(pytest.mark.integration) @pytest.fixture(scope="session") @@ -3285,6 +3287,8 @@ def store(tmp_path): return SessionStore(path=tmp_path / "sessions.json") ``` +Note: the marker must be scoped to the integration dir — an unscoped hook marks the entire test tree and silently deselects the unit suite. + - [ ] **Step 3: Verify collection behaves** Run: `uv run pytest --collect-only tests/integration` From 8fd9bd1fa0bd92eec4c5a4700d853b115c05886a Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Wed, 8 Jul 2026 22:42:25 -0600 Subject: [PATCH 29/35] test: end-to-end page-object flow on real headless browsers --- tests/integration/test_ephemeral.py | 60 +++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/integration/test_ephemeral.py diff --git a/tests/integration/test_ephemeral.py b/tests/integration/test_ephemeral.py new file mode 100644 index 0000000..ffb864c --- /dev/null +++ b/tests/integration/test_ephemeral.py @@ -0,0 +1,60 @@ +"""Drive the full page-object stack against a real headless browser.""" + +import pytest + +import wabot + +from .conftest import BROWSERS + + +class LoginPage(wabot.Page): + elements = { + "title": ("el", ("id", "page-title")), + "username": ("el", ("id", "username")), + "state": ("select", ("id", "state")), + "agree": ("checkbox", ("id", "agree")), + "submit": ("el", ("id", "submit-btn")), + } + + def verify(self): + return self.driver.title == "Login" + + def log_in(self, username): + self["username"].set_value(username) + self["state"].set_value(value="CO") + self["agree"].set_checked(True) + return self["submit"].click_and_go() + + +class WelcomePage(wabot.Page): + elements = {"greeting": ("el", ("id", "greeting"))} + + def verify(self): + return self.driver.title == "Welcome" + + +@pytest.mark.parametrize("browser_name", BROWSERS) +def test_login_flow_end_to_end(browser_name, site_url): + bot = wabot.browser(browser=browser_name, headless=True, pacing=wabot.NoPacing()) + try: + bot.driver.get(f"{site_url}/login.html") + assert bot.set_page(LoginPage) is True + assert bot.log_in("mathew") is True + assert bot.set_page(WelcomePage) is True + assert bot["greeting"].text == "You made it." + assert bot.good is True + finally: + bot.driver.quit() + + +@pytest.mark.parametrize("browser_name", BROWSERS) +def test_full_page_screenshot(browser_name, site_url, tmp_path): + bot = wabot.browser(browser=browser_name, headless=True, pacing=wabot.NoPacing()) + try: + bot.driver.get(f"{site_url}/login.html") + bot.set_page(LoginPage) + out = tmp_path / "shot.png" + assert bot.save_screenshot(str(out)) is True + assert out.stat().st_size > 0 + finally: + bot.driver.quit() From 2dd4d8c13cb231d32de31cf9d174cad7c19d1a5f Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Wed, 8 Jul 2026 22:47:16 -0600 Subject: [PATCH 30/35] test: flagship cross-process browser pickup + destroy cleanup --- tests/integration/test_persistence.py | 71 +++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/integration/test_persistence.py diff --git a/tests/integration/test_persistence.py b/tests/integration/test_persistence.py new file mode 100644 index 0000000..b913b32 --- /dev/null +++ b/tests/integration/test_persistence.py @@ -0,0 +1,71 @@ +"""The flagship feature: a second Python PROCESS picks up a saved browser.""" + +import json +import subprocess +import sys +import textwrap + +import pytest + +import wabot + +from .conftest import BROWSERS + +REATTACH_SCRIPT = textwrap.dedent( + """ + import json, sys + import wabot + from wabot.sessions import SessionStore + + store = SessionStore(path=sys.argv[1]) + bot = wabot.browser(session=sys.argv[2], pacing=wabot.NoPacing(), store=store) + value = bot.driver.find_element("id", "username").get_attribute("value") + print(json.dumps({"url": bot.driver.current_url, "username_value": value})) + """ +) + + +@pytest.mark.parametrize("browser_name", BROWSERS) +def test_second_process_picks_up_saved_browser(browser_name, site_url, store, tmp_path): + bot = wabot.browser( + session="flagship", browser=browser_name, headless=True, + pacing=wabot.NoPacing(), store=store, + ) + try: + bot.driver.get(f"{site_url}/login.html") + bot.driver.find_element("id", "username").send_keys("persisted-value") + + # simulate "the process exits": drop our handle without quitting + record = store.get("flagship") + assert record is not None and record.service_pid is not None + + result = subprocess.run( + [sys.executable, "-c", REATTACH_SCRIPT, str(store.path), "flagship"], + capture_output=True, text=True, timeout=120, check=True, + ) + payload = json.loads(result.stdout.strip().splitlines()[-1]) + assert payload["url"] == f"{site_url}/login.html" + assert payload["username_value"] == "persisted-value" + finally: + assert wabot.destroy("flagship", store=store) is True + + +def test_destroyed_session_is_really_gone(site_url, store): + from wabot.hosts import service_alive + + bot = wabot.browser( + session="doomed", browser="chromium", headless=True, + pacing=wabot.NoPacing(), store=store, + ) + bot.driver.get(f"{site_url}/login.html") + record = store.get("doomed") + assert wabot.destroy("doomed", store=store) is True + assert store.get("doomed") is None + assert wabot.destroy("doomed", store=store) is False + # the managed driver service was SIGTERMed + import time + + deadline = time.monotonic() + 10 + while time.monotonic() < deadline and service_alive(record.executor_url): + time.sleep(0.2) + assert not service_alive(record.executor_url) From 7e24d3445dedfd38c2459bf5db73f67eba929a47 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Wed, 8 Jul 2026 22:53:01 -0600 Subject: [PATCH 31/35] test: external WebDriver server mode (persist + ephemeral) --- tests/integration/test_external_server.py | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/integration/test_external_server.py diff --git a/tests/integration/test_external_server.py b/tests/integration/test_external_server.py new file mode 100644 index 0000000..8bb1268 --- /dev/null +++ b/tests/integration/test_external_server.py @@ -0,0 +1,58 @@ +"""host= mode: wabot connects to a WebDriver server it does not manage.""" + +import shutil +import subprocess +import time + +import pytest + +import wabot +from wabot.hosts import service_alive + + +@pytest.fixture +def external_chromedriver(): + binary = shutil.which("chromedriver") + if binary is None: + pytest.skip("chromedriver not on PATH") + proc = subprocess.Popen( + [binary, "--port=19515"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + url = "http://127.0.0.1:19515" + deadline = time.monotonic() + 15 + while time.monotonic() < deadline and not service_alive(url): + time.sleep(0.2) + assert service_alive(url), "chromedriver never came up" + yield url + proc.terminate() + proc.wait(timeout=10) + + +def test_persistent_session_on_external_host(external_chromedriver, site_url, store): + bot = wabot.browser( + session="ext", browser="chromium", host=external_chromedriver, + headless=True, pacing=wabot.NoPacing(), store=store, + ) + bot.driver.get(f"{site_url}/welcome.html") + record = store.get("ext") + assert record.executor_url == external_chromedriver + assert record.service_pid is None # not ours to kill + + # same process, second browser() call: must reattach, not create + bot2 = wabot.browser(session="ext", store=store, pacing=wabot.NoPacing()) + assert bot2.driver.session_id == record.session_id + assert bot2.driver.current_url == f"{site_url}/welcome.html" + + bot2.driver.quit() + store.remove("ext") + + +def test_ephemeral_on_external_host(external_chromedriver, site_url, store): + bot = wabot.browser( + host=external_chromedriver, browser="chromium", + headless=True, pacing=wabot.NoPacing(), store=store, + ) + bot.driver.get(f"{site_url}/welcome.html") + assert store.names() == [] # session=None never persists + bot.driver.quit() From 163f71e51bc6ad58dc294091e4f0948e33363c8f Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Wed, 8 Jul 2026 23:01:44 -0600 Subject: [PATCH 32/35] =?UTF-8?q?docs:=20sphinx=20site=20=E2=80=94=20quick?= =?UTF-8?q?start,=20sessions=20guide,=20page=20objects,=20API=20ref?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api.rst | 24 ++++++++++++++++++++ docs/conf.py | 14 ++++++++++++ docs/index.rst | 13 +++++++++++ docs/page-objects.rst | 47 +++++++++++++++++++++++++++++++++++++++ docs/quickstart.rst | 44 +++++++++++++++++++++++++++++++++++++ docs/sessions.rst | 51 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 193 insertions(+) create mode 100644 docs/api.rst create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/page-objects.rst create mode 100644 docs/quickstart.rst create mode 100644 docs/sessions.rst diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..75e7310 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,24 @@ +API reference +============= + +.. automodule:: wabot + :members: browser, sessions, destroy + +.. autoclass:: wabot.Browser + :members: + +.. autoclass:: wabot.Page + :members: + +.. automodule:: wabot.fields + :members: PageObject, TextField, SelectField, CheckField, NullField + +.. automodule:: wabot.pacing + :members: + +.. automodule:: wabot.sessions + :members: + :no-index: + +.. automodule:: wabot.hosts + :members: ExternalServer, ManagedService, attach, service_alive diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..e51236a --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,14 @@ +project = "wabot" +author = "Mathew Guest" +release = "0.2.0" + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", +] + +# sphinx_rtd_theme registers itself via entry point — do NOT add to extensions +html_theme = "sphinx_rtd_theme" + +autodoc_member_order = "bysource" +napoleon_google_docstring = True diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..5c3462c --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,13 @@ +wabot +===== + +Stateful Selenium browser automation with sessions that survive the +Python process. + +.. toctree:: + :maxdepth: 2 + + quickstart + sessions + page-objects + api diff --git a/docs/page-objects.rst b/docs/page-objects.rst new file mode 100644 index 0000000..ee269d6 --- /dev/null +++ b/docs/page-objects.rst @@ -0,0 +1,47 @@ +Page objects +============ + +Model each website page as a :class:`wabot.Page` subclass with a +class-level ``elements`` map; drive them through one +:class:`wabot.Browser`. + +.. code-block:: python + + class Inventory(wabot.Page): + elements = { + "search": ("el", ("id", "search-box")), + "region": ("select", ("id", "region")), + "instock": ("checkbox", ("id", "in-stock-only")), + "rows": ("els", ("css selector", "table#items tr")), + } + +Element types +------------- + +============ ======================================= ===================== +Type key Returned wrapper Main methods +============ ======================================= ===================== +``el`` :class:`wabot.TextField` get_value / set_value +``select`` :class:`wabot.SelectField` set_value(value|text) +``checkbox`` :class:`wabot.CheckField` get/set_checked +``els`` list of raw WebElements (selenium API) +a class your :class:`wabot.PageObject` subclass whatever you define +============ ======================================= ===================== + +Missing elements come back as a falsy :class:`wabot.NullField`, so guard +with ``if el:`` instead of try/except. + +Inheritance +----------- + +Element lookup walks the MRO: subclasses inherit the parent map and can +override individual keys — useful for sites with shared page chrome. + +Verification and delegation +--------------------------- + +``Browser.set_page(PageClass)`` refuses the switch if the page's +``verify()`` returns False. Once set, unknown attributes on the Browser +delegate to the current page: ``bot.log_in(...)`` calls +``bot.page.log_in(...)``. After any trapped failure the Browser refuses +further actions until ``bot.reset()``. diff --git a/docs/quickstart.rst b/docs/quickstart.rst new file mode 100644 index 0000000..a0e1f36 --- /dev/null +++ b/docs/quickstart.rst @@ -0,0 +1,44 @@ +Quickstart +========== + +Install +------- + +.. code-block:: bash + + uv add wabot # or: pip install . + +You also need Firefox or Chromium. Driver binaries (geckodriver / +chromedriver) are found on PATH or downloaded automatically by Selenium +Manager. + +First scrape +------------ + +.. code-block:: python + + import wabot + + class Login(wabot.Page): + elements = { + "username": ("el", ("id", "username")), + "password": ("el", ("id", "password")), + "submit": ("el", ("id", "submit-btn")), + } + + def verify(self): + return "Login" in self.driver.title + + def log_in(self, user, password): + self["username"].set_value(user) + self["password"].set_value(password) + return self["submit"].click_and_go() + + bot = wabot.browser(browser="firefox", headless=True) + bot.driver.get("https://example.com/login") + bot.set_page(Login) + bot.log_in("me", "secret") + +By default wabot paces itself like a human (multi-second random delays, +randomized click positions). Pass ``pacing=wabot.NoPacing()`` for full +speed on sites you trust. diff --git a/docs/sessions.rst b/docs/sessions.rst new file mode 100644 index 0000000..99590fc --- /dev/null +++ b/docs/sessions.rst @@ -0,0 +1,51 @@ +Persistent sessions +=================== + +The flagship feature: a browser created with a ``session`` name survives +the Python process, and any later process can pick it up by name. + +.. code-block:: python + + import wabot + + bot = wabot.browser(session="scraper1", browser="firefox") + bot.driver.get("https://example.com/login") + # ... log in, then the process exits; the browser stays open ... + + # hours later, a different process: + bot = wabot.browser(session="scraper1", browser="firefox") + print(bot.driver.current_url) # still logged in + +How it works +------------ + +A session is just ``(executor_url, session_id)`` stored as JSON in the +platform data dir. Nothing is pickled. The browser is hosted by a driver +service that outlives Python: + +* **Managed (default):** wabot spawns chromedriver/geckodriver as a + detached process and records its port and pid. +* **External:** pass ``host="http://localhost:4444"`` to use a Selenium + Grid or standalone server you run yourself. wabot then never kills the + server — only the session. + +Reattaching constructs a driver that *adopts* the saved session id +instead of creating a new session. Dead sessions (browser closed, server +gone, record older than 3 days) are pruned automatically and a fresh +browser is created instead. + +Housekeeping +------------ + +.. code-block:: python + + wabot.sessions() # ["scraper1", ...] + wabot.destroy("scraper1") # quit browser, stop managed service, forget + +Selenium Grid caveat +-------------------- + +Grid 4 kills idle sessions after **5 minutes** by default. If your +process sleeps longer between reattaches, raise it:: + + java -jar selenium-server.jar standalone --session-timeout 86400 From 5ec5ad615a35cf40c1c6db6e7a599c116c384219 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Wed, 8 Jul 2026 23:10:45 -0600 Subject: [PATCH 33/35] docs: rewrite README and CLAUDE.md for the modernized library 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/. --- .gitignore | 5 +++++ CLAUDE.md | 31 ++++++++++++++++++------------- README.md | 38 +++++++++++++++++++++++++++++++------- 3 files changed, 54 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 4ffc73e..71e897b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,11 @@ htmlcov/ .cache nosetests.xml coverage.xml +.pytest_cache/ + +# uv / ruff +.venv/ +.ruff_cache/ # Translations *.mo diff --git a/CLAUDE.md b/CLAUDE.md index c901b99..14ac831 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,28 +6,33 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co WABot is a Python library wrapping Selenium for stateful browser automation (login flows, multi-page form filling, scraping). Its two distinguishing features: -1. **Page-object automation with state carried between pages** — consumers model each website page as a `Page` subclass and drive them through a single `BrowserProxy`. -2. **Browser sessions that survive the Python process** — a remote webdriver reference is persisted to disk so a *different* Python process can reattach to the same still-open browser. +1. **Page-object automation with state carried between pages** — consumers model each website page as a `Page` subclass and drive them through a single `Browser` facade. +2. **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. -There is currently no test suite, linter, or CI. Packaging is a legacy `setup.py` (`pip install -e .` to develop). The code is pinned to Selenium 3-era APIs (`desired_capabilities`, `browser_profile`, `switch_to_alert`); it does not run against Selenium 4+ without migration. +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, `BrowserProxy` (`wabot/api.py`), which composes everything else: +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: -- **`BrowserProxy`** holds the webdriver plus a *current page* object and acts as a facade. `__getattr__` delegates unknown attributes to the current page, so `bot.login()` invokes `bot.page.login()`; `bot[key]` resolves page elements. `set_page(PageClass)` swaps the current page and calls its `verify()` gate. `perform(name, ...)` invokes a page method with exception trapping — any failure flips `self.good = False`, after which all further actions are refused (`REFUSE_AFTER_EXCEPTION`), forcing an explicit `reset_good_status()`. -- **`Page`** (`wabot/page.py`) is the base class consumers subclass. Pages declare a class-level `elements = {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** (`wabot/fields.py`): `TextField`, `SelectField`, `CheckField` wrap elements with `get_value`/`set_value`; unknown attribute access falls through to the underlying selenium element. `NullField` is a falsy null-object returned for missing elements so callers can `if el:` instead of catching exceptions. -- **`CreateBrowser`** (`wabot/create_browser.py`) is the driver factory. Local drivers (Chrome/Firefox) are plain. Remote drivers (Selenium server expected at `localhost:4444`) are serialized with `dill` to `appdirs.user_data_dir('wabot')/saved_browser_instances.pickle`, keyed by session name with a timestamp (refs older than 3 days are purged). On the next request for the same session name, the pickled driver is loaded and validated by touching `driver.current_url` — that is the reattach mechanism. +- **`Browser`** holds the webdriver plus a *current page* object and acts as a facade. `__getattr__` delegates unknown attributes to the current page, so `bot.login()` invokes `bot.page.login()`; `bot[key]` resolves page elements. `set_page(PageClass)` swaps the current page and calls its `verify()` gate. `perform(name, ...)` invokes a page method with exception trapping — any failure flips `self.good = False`, after which all further delegated actions are refused, forcing an explicit `reset()`. `quit()` tears down the driver and, for a wabot-managed service, stops the detached process too (a plain `driver.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-level `elements = {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`, `CheckField` wrap elements with `get_value`/`set_value`; unknown attribute access falls through to the underlying selenium element. `NullField` is a falsy null-object returned for missing elements so callers can `if el:` instead of catching exceptions. +- **Pacing policies** (`src/wabot/pacing.py`): `HumanPacing` (default) and `NoPacing`. Field wrappers and `Page` ask the active policy for a per-action delay and a click offset instead of hardcoding stealth behavior, so speed/stealth is swappable per `Browser` instance. +- **Sessions** (`src/wabot/sessions.py`): `SessionRecord`/`SessionStore` persist only what's needed to reattach — `executor_url` and `session_id`, plus `service_pid`/`service_port` bookkeeping for managed services — as JSON at `platformdirs.user_data_dir("wabot")/sessions.json`, keyed by session name. Records older than `max_age` (default 3 days) are evicted on load; a corrupt store is quarantined to `sessions.json.bad` rather than crashing the caller. +- **Driver hosts** (`src/wabot/hosts.py`): two ways to obtain a WebDriver server URL. `ExternalServer` points at a Selenium Grid or bare driver the user already runs (validated via `GET /status`). `ManagedService` spawns `chromedriver`/`geckodriver` itself, detached (`start_new_session=True`) so it survives this Python process exiting; `stop_service(pid)` is how `Browser.quit()`/`destroy()` shut it back down. `ReattachingRemote.start_session` skips `NEW_SESSION` entirely and adopts a saved `session_id`; `attach()` builds one and probes `driver.current_url` to confirm the session is still live before handing it back. +- **`wabot.browser()`** (`src/wabot/__init__.py`) is the public entry point tying it together: no `session=` 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 raw `moz/screenshot/full` command 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**: clicks land at Gaussian-random coordinates within the element instead of Selenium's default (0,0), and field setters `sleep(random.uniform(...))` between actions. Chrome full-page screenshots are stitched from viewport tiles with PIL to work around chromedriver capturing only the visible area. Don't "optimize" these away. +**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 -- `LOGGER.trace(...)` is called throughout, but stdlib `logging` has no `trace` method — the consuming application is expected to register a custom TRACE level; without it those calls raise. -- `appdirs` and `Pillow` are imported but missing from `install_requires` in `setup.py` (only `dill` and `selenium` are declared). -- `wabot/__init__.py` star-exports `api`, `page`, and `fields`, so anything public in those modules is public API. -- Remote driver types require an external Selenium server already running on port 4444; nothing in this repo starts one. +- External hosts (`ExternalServer`) must outlive the Python process — wabot never starts or stops them. Only `ManagedService`-spawned drivers are wabot's to stop. +- geckodriver reports `ready: false` on `GET /status` whenever its single session is in use; chromedriver reports `ready: true` even with an active session. Liveness checks (`service_alive`) therefore accept any HTTP 200 with valid JSON and deliberately ignore the `ready` flag — requiring it would work for chromedriver and break for geckodriver. +- Selenium is pinned `<5`: `ReattachingRemote` relies on internals that have been stable across the 4.x line but aren't a public API contract (that `start_session` is the only place a new session gets created, and that `session_id` is injected into every subsequent command). `ReattachingRemote.__init__` raises `RuntimeError` on adoption mismatch as a tripwire if a future selenium release changes this. ## Git diff --git a/README.md b/README.md index eec5eaf..6426a0b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,33 @@ -# WABot - Web Automator Bot (python module) +# WABot — Web Automator Bot -Module provides a class which builds on selenium automated-browser control. What it adds -is the ability to carry and maintain-state between web pages. So for e.g. you can automate -a web browser to log-in, navigate through the menus, and fill out a form-- or scrape data. +Stateful Selenium browser automation for Python. Model pages as classes, +drive them through one facade, and — the flagship — keep the browser +alive after your process exits so another Python process can pick it up +by name. -Probably pretty awful code, I'd write it a lot better if I had more time on it. Though I've -used and continue to use to scrape 100,000s of data points with zero human time, performing -complex interactions with websites.k +```python +import wabot + +bot = wabot.browser(session="scraper1", browser="firefox") +bot.driver.get("https://example.com/login") +# ... log in; process exits; browser stays open ... + +# a different process, later: +bot = wabot.browser(session="scraper1", browser="firefox") # reattaches +``` + +Human-like pacing (random delays, randomized click positions) is on by +default; pass `pacing=wabot.NoPacing()` for full speed. + +## Development + +```bash +uv sync # install everything +uv run pytest # unit tests (fast, no browsers) +uv run pytest -m integration # real headless-browser tests +uv run ruff check . && uv run ruff format --check . +uv run sphinx-build -M html docs docs/_build # docs +``` + +Docs: quickstart, persistent-sessions guide, and API reference under +`docs/` (build locally with the command above). From 68978a34e7c2f66db1849f516caafaf544010da7 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Wed, 8 Jul 2026 23:12:23 -0600 Subject: [PATCH 34/35] style: apply ruff format across the codebase Co-Authored-By: Claude Opus 4.8 --- src/wabot/_browser.py | 8 ++------ src/wabot/page.py | 4 +--- tests/integration/conftest.py | 4 +--- tests/integration/test_external_server.py | 18 +++++++++++++----- tests/integration/test_persistence.py | 19 ++++++++++++++----- tests/unit/test_api.py | 23 ++++++++++++++++------- tests/unit/test_browser.py | 2 +- tests/unit/test_page.py | 4 +--- tests/unit/test_reattach.py | 4 +--- tests/unit/test_screenshot.py | 23 ++++++++++++----------- 10 files changed, 62 insertions(+), 47 deletions(-) diff --git a/src/wabot/_browser.py b/src/wabot/_browser.py index 8a62c0a..9115964 100644 --- a/src/wabot/_browser.py +++ b/src/wabot/_browser.py @@ -31,9 +31,7 @@ from .pacing import HumanPacing LOGGER = logging.getLogger("wabot") # attributes that must never be delegated to the page -_OWN_ATTRS = frozenset( - {"driver", "pacing", "session_name", "good", "page", "_store"} -) +_OWN_ATTRS = frozenset({"driver", "pacing", "session_name", "good", "page", "_store"}) def _refused(*_args, **_kwargs): @@ -69,9 +67,7 @@ class Browser: if name in _OWN_ATTRS or name.startswith("__"): raise AttributeError(name) if self.page is None: - raise AttributeError( - f"{name!r}: no current page — call set_page() first" - ) + raise AttributeError(f"{name!r}: no current page — call set_page() first") if not self.good: LOGGER.warning("broken state — refusing page action %r (call reset())", name) return _refused diff --git a/src/wabot/page.py b/src/wabot/page.py index d286ef1..db57268 100644 --- a/src/wabot/page.py +++ b/src/wabot/page.py @@ -72,9 +72,7 @@ class Page: LOGGER.warning("element not in page map: %s", key) return NullField(name=key) if not isinstance(locators, (tuple, list)) or len(locators) < 2: - LOGGER.error( - "malformed element %r: expected (type, accessors), got %r", key, locators - ) + LOGGER.error("malformed element %r: expected (type, accessors), got %r", key, locators) return NullField(name=key) obj_type, accessors = locators[0], locators[1] # built-in types need a (by, value) accessors pair; a custom field class diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index abfa883..7a32652 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -26,9 +26,7 @@ def pytest_collection_modifyitems(items): @pytest.fixture(scope="session") def site_url(): """Serve tests/fixtures over HTTP on an ephemeral port.""" - handler = functools.partial( - http.server.SimpleHTTPRequestHandler, directory=str(FIXTURES) - ) + handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(FIXTURES)) server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() diff --git a/tests/integration/test_external_server.py b/tests/integration/test_external_server.py index 8bb1268..d38dcef 100644 --- a/tests/integration/test_external_server.py +++ b/tests/integration/test_external_server.py @@ -17,7 +17,8 @@ def external_chromedriver(): pytest.skip("chromedriver not on PATH") proc = subprocess.Popen( [binary, "--port=19515"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) url = "http://127.0.0.1:19515" deadline = time.monotonic() + 15 @@ -31,8 +32,12 @@ def external_chromedriver(): def test_persistent_session_on_external_host(external_chromedriver, site_url, store): bot = wabot.browser( - session="ext", browser="chromium", host=external_chromedriver, - headless=True, pacing=wabot.NoPacing(), store=store, + session="ext", + browser="chromium", + host=external_chromedriver, + headless=True, + pacing=wabot.NoPacing(), + store=store, ) bot.driver.get(f"{site_url}/welcome.html") record = store.get("ext") @@ -50,8 +55,11 @@ def test_persistent_session_on_external_host(external_chromedriver, site_url, st def test_ephemeral_on_external_host(external_chromedriver, site_url, store): bot = wabot.browser( - host=external_chromedriver, browser="chromium", - headless=True, pacing=wabot.NoPacing(), store=store, + host=external_chromedriver, + browser="chromium", + headless=True, + pacing=wabot.NoPacing(), + store=store, ) bot.driver.get(f"{site_url}/welcome.html") assert store.names() == [] # session=None never persists diff --git a/tests/integration/test_persistence.py b/tests/integration/test_persistence.py index b913b32..e5739a0 100644 --- a/tests/integration/test_persistence.py +++ b/tests/integration/test_persistence.py @@ -28,8 +28,11 @@ REATTACH_SCRIPT = textwrap.dedent( @pytest.mark.parametrize("browser_name", BROWSERS) def test_second_process_picks_up_saved_browser(browser_name, site_url, store, tmp_path): bot = wabot.browser( - session="flagship", browser=browser_name, headless=True, - pacing=wabot.NoPacing(), store=store, + session="flagship", + browser=browser_name, + headless=True, + pacing=wabot.NoPacing(), + store=store, ) try: bot.driver.get(f"{site_url}/login.html") @@ -41,7 +44,10 @@ def test_second_process_picks_up_saved_browser(browser_name, site_url, store, tm result = subprocess.run( [sys.executable, "-c", REATTACH_SCRIPT, str(store.path), "flagship"], - capture_output=True, text=True, timeout=120, check=True, + capture_output=True, + text=True, + timeout=120, + check=True, ) payload = json.loads(result.stdout.strip().splitlines()[-1]) assert payload["url"] == f"{site_url}/login.html" @@ -54,8 +60,11 @@ def test_destroyed_session_is_really_gone(site_url, store): from wabot.hosts import service_alive bot = wabot.browser( - session="doomed", browser="chromium", headless=True, - pacing=wabot.NoPacing(), store=store, + session="doomed", + browser="chromium", + headless=True, + pacing=wabot.NoPacing(), + store=store, ) bot.driver.get(f"{site_url}/login.html") record = store.get("doomed") diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 186a58b..50893b4 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -158,11 +158,17 @@ class TestResourceCleanup: stopped = [] monkeypatch.setattr(wabot, "stop_service", lambda pid: stopped.append(pid)) - store.save(wabot.SessionRecord( - name="s1", executor_url="http://127.0.0.1:7777", session_id="old", - browser="chromium", created_at=datetime.now(timezone.utc).isoformat(), - service_pid=9999, service_port=7777, - )) + store.save( + wabot.SessionRecord( + name="s1", + executor_url="http://127.0.0.1:7777", + session_id="old", + browser="chromium", + created_at=datetime.now(timezone.utc).isoformat(), + service_pid=9999, + service_port=7777, + ) + ) fake_selenium.service_alive.return_value = True fake_selenium.attach.return_value = None # session dead on a live server wabot.browser(session="s1", store=store) @@ -176,8 +182,11 @@ class TestDestroyEdges: from datetime import datetime, timezone defaults = dict( - name="s1", executor_url="http://127.0.0.1:7777", session_id="old", - browser="chromium", created_at=datetime.now(timezone.utc).isoformat(), + name="s1", + executor_url="http://127.0.0.1:7777", + session_id="old", + browser="chromium", + created_at=datetime.now(timezone.utc).isoformat(), ) defaults.update(kw) store.save(wabot.SessionRecord(**defaults)) diff --git a/tests/unit/test_browser.py b/tests/unit/test_browser.py index c6cab80..c88d91c 100644 --- a/tests/unit/test_browser.py +++ b/tests/unit/test_browser.py @@ -97,7 +97,7 @@ class TestPerform: bot.set_page(Login) bot.perform("explode") assert bot.perform("do_login") is None # refused - assert bot.do_login() is None # __getattr__ path also refused + assert bot.do_login() is None # __getattr__ path also refused bot.reset() assert bot.perform("do_login") == "logged-in" diff --git a/tests/unit/test_page.py b/tests/unit/test_page.py index 99823aa..e238fac 100644 --- a/tests/unit/test_page.py +++ b/tests/unit/test_page.py @@ -73,9 +73,7 @@ class TestGetProxy: browser = make_browser() page = BasePage(browser) result = page["rows"] - browser.driver.find_elements.assert_called_once_with( - by="css selector", value="tr.row" - ) + browser.driver.find_elements.assert_called_once_with(by="css selector", value="tr.row") assert result is browser.driver.find_elements.return_value def test_unknown_key_returns_falsy_nullfield(self): diff --git a/tests/unit/test_reattach.py b/tests/unit/test_reattach.py index 8b3c1c7..11ead0e 100644 --- a/tests/unit/test_reattach.py +++ b/tests/unit/test_reattach.py @@ -16,9 +16,7 @@ class TestReattachingRemote: assert isinstance(driver.caps, dict) def test_works_for_firefox_options_too(self): - driver = ReattachingRemote( - "http://127.0.0.1:1", "sid", options=build_options("firefox") - ) + driver = ReattachingRemote("http://127.0.0.1:1", "sid", options=build_options("firefox")) assert driver.session_id == "sid" diff --git a/tests/unit/test_screenshot.py b/tests/unit/test_screenshot.py index 126430f..500dc99 100644 --- a/tests/unit/test_screenshot.py +++ b/tests/unit/test_screenshot.py @@ -62,7 +62,8 @@ class TestChromiumPath: assert Image.open(out).size == (100, 150) # scrolled at least twice beyond the initial position scroll_calls = [ - c for c in driver.execute_script.call_args_list + c + for c in driver.execute_script.call_args_list if c.args and str(c.args[0]).startswith("window.scrollTo") ] assert len(scroll_calls) >= 2 @@ -77,17 +78,17 @@ class TestChromiumPath: "return window.innerHeight": 60, }.get(script) # window.scrollTo(...) -> None driver.get_screenshot_as_png.side_effect = [ - png_bytes(100, 60, (255, 0, 0)), # tile @ y=0 - png_bytes(100, 60, (0, 255, 0)), # tile @ y=60 - png_bytes(100, 60, (0, 0, 255)), # tile @ y=120, clamped to paste_y=90 + png_bytes(100, 60, (255, 0, 0)), # tile @ y=0 + png_bytes(100, 60, (0, 255, 0)), # tile @ y=60 + png_bytes(100, 60, (0, 0, 255)), # tile @ y=120, clamped to paste_y=90 ] out = tmp_path / "shot.png" assert save_full_page(driver, str(out)) is True img = Image.open(out) assert img.size == (100, 150) - assert img.getpixel((50, 30)) == (255, 0, 0) # first tile - assert img.getpixel((50, 75)) == (0, 255, 0) # second tile (rows 60-89) - assert img.getpixel((50, 140)) == (0, 0, 255) # third tile, bottom-aligned + assert img.getpixel((50, 30)) == (255, 0, 0) # first tile + assert img.getpixel((50, 75)) == (0, 255, 0) # second tile (rows 60-89) + assert img.getpixel((50, 140)) == (0, 0, 255) # third tile, bottom-aligned def test_wide_page_clamps_last_column(self, tmp_path): driver = MagicMock(name="driver") @@ -99,12 +100,12 @@ class TestChromiumPath: "return window.innerHeight": 60, }.get(script) driver.get_screenshot_as_png.side_effect = [ - png_bytes(100, 60, (255, 0, 0)), # column @ x=0 - png_bytes(100, 60, (0, 0, 255)), # column @ x=100, clamped to paste_x=50 + png_bytes(100, 60, (255, 0, 0)), # column @ x=0 + png_bytes(100, 60, (0, 0, 255)), # column @ x=100, clamped to paste_x=50 ] out = tmp_path / "shot.png" assert save_full_page(driver, str(out)) is True img = Image.open(out) assert img.size == (150, 60) - assert img.getpixel((25, 30)) == (255, 0, 0) # left column (cols 0-49) - assert img.getpixel((140, 30)) == (0, 0, 255) # right column, clamped (cols 50-149) + assert img.getpixel((25, 30)) == (255, 0, 0) # left column (cols 0-49) + assert img.getpixel((140, 30)) == (0, 0, 255) # right column, clamped (cols 50-149) From 154344e94a1bc2ee0739da1e613e8afb6522a238 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Sun, 12 Jul 2026 15:37:56 -0600 Subject: [PATCH 35/35] refactor: rename sessions submodule to _sessions to avoid shadowing sessions() wabot/__init__.py defines def sessions(...) and also had a sessions.py submodule, so wabot.sessions (attribute) resolved to the function, shadowing the module for import wabot.sessions as m style access. This is the same class of collision that browser.py -> _browser.py already fixed. Renaming to _sessions.py removes the ambiguity and lets docs/api.rst drop the :no-index: Sphinx workaround in favor of plain autoclass directives on the public SessionRecord/SessionStore re-exports. --- docs/api.rst | 6 ++-- .../plans/2026-07-07-wabot-modernization.md | 29 ++++++++++--------- src/wabot/__init__.py | 2 +- src/wabot/{sessions.py => _sessions.py} | 0 tests/integration/conftest.py | 2 +- tests/integration/test_persistence.py | 2 +- tests/unit/test_api.py | 2 +- tests/unit/test_sessions.py | 4 +-- 8 files changed, 26 insertions(+), 21 deletions(-) rename src/wabot/{sessions.py => _sessions.py} (100%) diff --git a/docs/api.rst b/docs/api.rst index 75e7310..8007b85 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -16,9 +16,11 @@ API reference .. automodule:: wabot.pacing :members: -.. automodule:: wabot.sessions +.. autoclass:: wabot.SessionRecord + :members: + +.. autoclass:: wabot.SessionStore :members: - :no-index: .. automodule:: wabot.hosts :members: ExternalServer, ManagedService, attach, service_alive diff --git a/docs/superpowers/plans/2026-07-07-wabot-modernization.md b/docs/superpowers/plans/2026-07-07-wabot-modernization.md index 1ba359d..15dc6a9 100644 --- a/docs/superpowers/plans/2026-07-07-wabot-modernization.md +++ b/docs/superpowers/plans/2026-07-07-wabot-modernization.md @@ -44,7 +44,7 @@ wabot/ (repo root, branch feature/modernization ├── src/wabot/ │ ├── __init__.py # public API: browser(), sessions(), destroy(), re-exports │ ├── pacing.py # NoPacing / HumanPacing -│ ├── sessions.py # SessionRecord, SessionStore +│ ├── _sessions.py # SessionRecord, SessionStore (underscored to avoid shadowing sessions()) │ ├── hosts.py # build_options, service_alive, ExternalServer, │ │ # ManagedService, find_driver_binary, stop_service, │ │ # ReattachingRemote, attach @@ -291,10 +291,10 @@ git commit -m "feat: pacing policies (HumanPacing default behavior, NoPacing for ``` --- -### Task 3: `sessions.py` — SessionRecord + SessionStore +### Task 3: `_sessions.py` — SessionRecord + SessionStore **Files:** -- Create: `src/wabot/sessions.py` +- Create: `src/wabot/_sessions.py` - Test: `tests/unit/test_sessions.py` - [ ] **Step 1: Write the failing tests** @@ -304,7 +304,7 @@ git commit -m "feat: pacing policies (HumanPacing default behavior, NoPacing for ```python from datetime import datetime, timedelta, timezone -from wabot.sessions import SessionRecord, SessionStore +from wabot._sessions import SessionRecord, SessionStore def make_record(name="scraper1", created_at=None): @@ -367,7 +367,7 @@ class TestSessionStore: assert store.get("scraper1") is not None def test_default_path_is_under_platformdirs(self): - from wabot.sessions import default_store_path + from wabot._sessions import default_store_path assert default_store_path().name == "sessions.json" assert "wabot" in str(default_store_path()) @@ -432,11 +432,11 @@ class TestSessionStore: - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/unit/test_sessions.py -v` -Expected: FAIL — `ModuleNotFoundError: No module named 'wabot.sessions'` +Expected: FAIL — `ModuleNotFoundError: No module named 'wabot._sessions'` - [ ] **Step 3: Write the implementation** -`src/wabot/sessions.py`: +`src/wabot/_sessions.py`: ```python """Persistence of reattachable browser sessions as plain JSON. @@ -573,7 +573,7 @@ Expected: 14 passed - [ ] **Step 5: Commit** ```bash -git add src/wabot/sessions.py tests/unit/test_sessions.py +git add src/wabot/_sessions.py tests/unit/test_sessions.py git commit -m "feat: JSON SessionStore with stale eviction (replaces pickle persistence)" ``` @@ -2773,7 +2773,7 @@ from unittest.mock import MagicMock import pytest import wabot -from wabot.sessions import SessionRecord, SessionStore +from wabot import SessionRecord, SessionStore @pytest.fixture @@ -3042,7 +3042,7 @@ from .hosts import ( ) from .page import Page from .pacing import HumanPacing, NoPacing -from .sessions import SessionRecord, SessionStore +from ._sessions import SessionRecord, SessionStore __all__ = [ "Browser", @@ -3282,7 +3282,7 @@ def site_url(): @pytest.fixture def store(tmp_path): - from wabot.sessions import SessionStore + from wabot import SessionStore return SessionStore(path=tmp_path / "sessions.json") ``` @@ -3422,7 +3422,7 @@ REATTACH_SCRIPT = textwrap.dedent( """ import json, sys import wabot - from wabot.sessions import SessionStore + from wabot import SessionStore store = SessionStore(path=sys.argv[1]) bot = wabot.browser(session=sys.argv[2], pacing=wabot.NoPacing(), store=store) @@ -3807,7 +3807,10 @@ API reference .. automodule:: wabot.pacing :members: -.. automodule:: wabot.sessions +.. autoclass:: wabot.SessionRecord + :members: + +.. autoclass:: wabot.SessionStore :members: .. automodule:: wabot.hosts diff --git a/src/wabot/__init__.py b/src/wabot/__init__.py index 9aa32b1..84f3e00 100644 --- a/src/wabot/__init__.py +++ b/src/wabot/__init__.py @@ -19,6 +19,7 @@ from selenium import webdriver from selenium.common.exceptions import WebDriverException from ._browser import Browser +from ._sessions import SessionRecord, SessionStore from .fields import CheckField, NullField, PageObject, SelectField, TextField from .hosts import ( ExternalServer, @@ -31,7 +32,6 @@ from .hosts import ( ) from .pacing import HumanPacing, NoPacing from .page import Page -from .sessions import SessionRecord, SessionStore __all__ = [ "Browser", diff --git a/src/wabot/sessions.py b/src/wabot/_sessions.py similarity index 100% rename from src/wabot/sessions.py rename to src/wabot/_sessions.py diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 7a32652..b959dbe 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -36,6 +36,6 @@ def site_url(): @pytest.fixture def store(tmp_path): - from wabot.sessions import SessionStore + from wabot import SessionStore return SessionStore(path=tmp_path / "sessions.json") diff --git a/tests/integration/test_persistence.py b/tests/integration/test_persistence.py index e5739a0..cd7d3ba 100644 --- a/tests/integration/test_persistence.py +++ b/tests/integration/test_persistence.py @@ -15,7 +15,7 @@ REATTACH_SCRIPT = textwrap.dedent( """ import json, sys import wabot - from wabot.sessions import SessionStore + from wabot import SessionStore store = SessionStore(path=sys.argv[1]) bot = wabot.browser(session=sys.argv[2], pacing=wabot.NoPacing(), store=store) diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 50893b4..28df3d6 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock import pytest import wabot -from wabot.sessions import SessionRecord, SessionStore +from wabot import SessionRecord, SessionStore @pytest.fixture diff --git a/tests/unit/test_sessions.py b/tests/unit/test_sessions.py index c29433d..cb4e5ab 100644 --- a/tests/unit/test_sessions.py +++ b/tests/unit/test_sessions.py @@ -1,6 +1,6 @@ from datetime import datetime, timedelta, timezone -from wabot.sessions import SessionRecord, SessionStore +from wabot._sessions import SessionRecord, SessionStore def make_record(name="scraper1", created_at=None): @@ -63,7 +63,7 @@ class TestSessionStore: assert store.get("scraper1") is not None def test_default_path_is_under_platformdirs(self): - from wabot.sessions import default_store_path + from wabot._sessions import default_store_path assert default_store_path().name == "sessions.json" assert "wabot" in str(default_store_path())