Skip to main content

purecdp

Pure-Python (stdlib-only) library for the Chrome DevTools Protocol, targeting Python 3.14+.

Three things, layered:

  1. purecdp.protocol — low-level typed bindings, generated from the official devtools-protocol JSON specs. Names are bare — TargetInfo.type, Frame.id, dispatch_mouse_event(type=...) — not the type_/id_ trailing-underscore convention some CDP generators use (they aren't Python keywords, so nothing needs mangling).
  2. purecdp core — sans-I/O protocol engine + asyncio transports (websocket / pipe) + browser launcher.
  3. purecdp.testing — opinionated helpers for driving a browser in tests, incl. a pytest plugin.

See Examples.md for task-grouped recipes (basics, testing, stealth, LLM agents, frames).

Quickstart

import asyncio
import purecdp
from purecdp.protocol import page, runtime

async def main():
    async with await purecdp.launch() as browser:   # finds Chromium/Chrome/Brave
        session = await browser.new_session()          # create target + attach

        await session.execute(page.enable())
        waiter = asyncio.create_task(session.wait_for(page.LoadEventFired))
        await asyncio.sleep(0)  # subscribe before triggering
        await session.execute(page.navigate(url="https://example.com"))
        await waiter

        result, _ = await session.execute(
            runtime.evaluate(expression="document.title", return_by_value=True))
        print(result.value)

asyncio.run(main())

purecdp.launch(pipe=True) uses --remote-debugging-pipe instead of a websocket. browser.new_page() is the one-call convenience — a new tab as a ready testing.Page. purecdp.connect("http://127.0.0.1:9222") (or host=/port=, or a ws:// endpoint) attaches to a browser someone else started — notably a browser a human just logged into (password, MFA), so an agent can inherit the authenticated session while the credentials never touch the automation; a new tab shares the profile's cookies, so it's signed in from birth — and browser.attach(url_contains="kais") grabs an already-open tab as a ready Page (no match, or several, raises TargetNotFound naming the tabs that do exist). browser.new_context() gives an isolated cookies/storage context (cheap per-test isolation); connection.set_auto_attach() auto-attaches popups/workers/OOPIFs, resuming paused targets automatically.

Writing tests with purecdp

The stdlib way (purecdp.testing.CDPTestCase — fresh browser, isolated context, and a Page per test):

from purecdp.testing import CDPTestCase, JSError

class MyAppTests(CDPTestCase):
    async def test_stubbed_api(self):
        async def api(request):
            await request.fulfill(body='{"items": []}',
                                  content_type="application/json")
        await self.page.route("*/api/*", api)
        await self.page.goto("https://myapp.example/", wait="idle")
        await self.page.wait_for("#empty-state")
        assert not self.page.js_errors

Page gives you goto (load/idle waits), evaluate (promises awaited, JSError raised; values pass as argumentsevaluate("(a, b) => a + b", x, y) — never interpolated into JS source), wait_for(selector, **conditions) (present / visible=False gone-or-hidden / count=0 absent — the should() vocabulary), wait_for_function, click, query/query_all (held elements, text-filtered, stale-copy-proof), screenshot, always-on page.console / page.js_errors capture, and page.route() request stubbing/rewriting/aborting. For traffic-level assertions there's page.record() (full request/response exchanges — the wire headers, Cookie / Origin / Set-Cookie included, plus bodies, and parse_sse() for streamed ones) and page.expect_download() (capture a download's bytes without touching disk); arm recorder.expect() before a click and await .value for the response it triggers (json=True skips interleaved non-JSON bodies), and recorder.save_har() writes the whole capture as a HAR any devtools opens. Sensitive flows keep their privacy: record(bodies=False) captures metadata only, and record(redact=...) makes matching exchanges fully private (no bodies, credential headers masked) — the secret never enters the process. And when a test fails, its pages are dumped as diagnostic artifacts — screenshot, HTML, console, recorded traffic, traceback — under purecdp-artifacts/<test id>/ before the browser closes; green tests write nothing. Long-running watchers get an honest give-up signal: page.alive (False once the tab or browser is gone; no round-trip) and the CDPClosedError base (CDPSessionClosed + CDPConnectionClosed), so while page.alive: with except CDPClosedError: break can never spin on a dead tab.

For SPA-proof tests there are lazy locators: page.live(selector) and get_by_test_id / get_by_text / get_by_role / get_by_label hold the query, not a node — every action re-resolves against the current DOM (a React remount can't leave you a stale handle), auto-waits for actionability, and one .should(...) gives polling assertions:

await page.get_by_test_id("chat-input").fill("hello")
await page.get_by_role("button", name="Send").click()
await page.live(".msg", containing="hello").should(visible=True, count=1)
await page.live(".spinner").should(count=0)          # waits until it's gone

Prefer pytest? The optional plugin (auto-registered when installed, or -p purecdp.testing.pytest_plugin) provides a session-scoped cdp_browser and per-test cdp_page, and runs async def tests itself — no pytest-asyncio required:

async def test_title(cdp_page):
    await cdp_page.goto("data:text/html,<title>hi</title>")
    assert await cdp_page.title() == "hi"

Low-observability / stealth

For automation that shouldn't advertise itself, purecdp layers three opt-in pieces (reduces common detection signals — not a promise of undetectability; CreepJS-class and TLS/HTTP2 fingerprints still identify automation):

import purecdp
from purecdp.testing import Page, apply_stealth

async with await purecdp.launch(stealth=True) as browser:   # AutomationControlled off, headless=new
    session = await browser.new_session()
    page = await Page.create(session, capture=False, track_network=False)  # no Runtime.enable leak
    await apply_stealth(page)                                # fingerprint init-scripts, before goto
    await page.goto("https://example.com")

apply_stealth(page, evasions=[...]) selects a subset; keyword overrides (webgl_vendor, webgl_renderer, languages, vendor, hardware_concurrency) tune the spoofed values.

For behavioral realism (the more durable signal — how input moves, all over trusted Input events):

el = await page.query("#submit")
await el.human_click()             # curved, eased path to a near-center point
await page.human_type("hello@example.com")   # per-key events, human cadence
await page.human_scroll(1200)      # eased wheel steps, not one jump

Since purecdp drives a real browser, its TLS/HTTP2 (JA3/JA4) fingerprint is already authentically Chrome's — no spoofing needed, unlike browserless HTTP scrapers.

Field notes

Lessons from real scraping/automation runs, cheapest first:

  • Check for SSR state before driving a browser. Many SPAs ship their whole data model inside the HTML — <script id="serverApp-state" type="application/json"> (Angular Universal), __NEXT_DATA__ (Next.js), __NUXT__ (Nuxt). One plain HTTP GET plus json.loads beats a browser session in speed and reliability; purecdp is for pages that genuinely render client-side.
  • Heavy SPAs never fire load quickly. goto waits for 'load' with a 10 s default; for Google-Maps-class pages pass await page.goto(url, wait='domcontentloaded') (or raise timeout=) and poll for the element you need.
  • The reliable evaluate idiom is strings both ways: JSON.stringify(...) on the JS side, json.loads(...) on the Python side. It sidesteps any question of how richer values marshal.
  • Headed mode can leave the profile locked. After a headed run a browser process could keep holding user_data_dir — a wrapper script re-execs, so signalling the spawned child may miss the real browser. aclose() therefore closes gracefully first: Browser.close over CDP reaches the real browser whatever its PID, and it exits cleanly, releasing the lock (signals are only the fallback). If a profile is still held, launch() diagnoses it instead of failing obscurely: a SingletonLock pointing at a live process raises BrowserLaunchError: profile … in use by PID N, a stale DevToolsActivePort is cleared (it used to surface as a baffling ConnectionRefusedError to a dead port), and any launch failure quotes the browser's own stderr.
  • Search-engine bot walls are IP-reputation, not fingerprints. From a flagged IP, stealth=True does not move the needle on google.com/search — yet Google Maps place pages load fine in the same session, and independent engines captcha on volume. Pick targets, not evasions.

Driving with an LLM (agent surface)

page.snapshot() returns a compact, ref-tagged view of the page from the accessibility tree — small and stable enough to hand an LLM, instead of raw HTML or screenshots. Each control gets a session ref (e1, e2, …); password fields are masked.

snap = await page.snapshot()
print(snap)
# - RootWebArea "Login"
#   - textbox "Email" [e1]
#   - textbox "Password" = "••••••" [e2]
#   - button "Sign in" [e3]

await page.act("e1", "fill", text="me@example.com")   # fill/type/select/click/…
await page.act("e3", "click")
el = await page.element_for_ref("e1")                  # escape hatch: full Element API

An optional MCP server exposes this to any Model Context Protocol host — no extra dependency, stdlib JSON-RPC over stdio:

python -m purecdp.mcp            # navigate/snapshot/act + network/wait/seed tools
python -m purecdp.mcp --stealth  # low-observability launch
python -m purecdp.mcp --allow-eval   # also expose the JS evaluate tool
python -m purecdp.mcp --allow-mock   # also expose the response-stubbing tool

Beyond driving the DOM, the server exposes the recorder so an agent can assert on the wire: after act, the returned cursor feeds requests(since=cursor) to see what the click fired, then request(id, select="a.b") reads just that part of the response body (semantic projection, so a huge body doesn't flood the agent's context). act targets an element by snapshot ref or by description (by=text/role/testid/label); snapshot returns the ref-tagged outline or (format="json") a flat list of {ref, role, name} rows, and (scope="dialog") narrows to the open modal's controls; screenshot returns a PNG image block for visual state; plus wait (until visible/hidden/ count/text), seed_storage / set_cookie (plant an auth session before navigating), record (narrow capture), and mock (stub an endpoint, opt-in).

Layout

spec/               vendored protocol JSON + PIN (source commit)
src/purecdp/            the library (src layout)
src/purecdp/_generator/ code generator (dev tool, reads spec/, writes src/purecdp/protocol/)
src/purecdp/protocol/   GENERATED bindings — do not edit by hand
tests/              tests and test utilities

Regenerating the bindings

python -m purecdp._generator          # uses spec/ and src/purecdp/protocol/ defaults

Update spec/*.json + spec/PIN from a devtools-protocol checkout first when moving the pin.

Running tests

Stdlib only — no test dependencies:

python -m unittest              # discovery from repo root
python tests/test_protocol.py   # single file works too

python -m pytest also works (nicer output, subtest counts) but is never required.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

purecdp-0.8.0.tar.gz (420.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

purecdp-0.8.0-py3-none-any.whl (406.1 kB view details)

Uploaded Python 3

File details

Details for the file purecdp-0.8.0.tar.gz.

File metadata

  • Download URL: purecdp-0.8.0.tar.gz
  • Upload date:
  • Size: 420.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for purecdp-0.8.0.tar.gz
Algorithm Hash digest
SHA256 607e29e6927f8f094aec2ec4421abafa02153c87bf9220edcba2013503dd3b5b
MD5 75b3a9d6942702e22f44243f14c21c74
BLAKE2b-256 79b15474e63b0508be8e2cff27f8882576aeb5f1fc4178cdcc8412bd24ceef44

See more details on using hashes here.

Provenance

The following attestation bundles were made for purecdp-0.8.0.tar.gz:

Publisher: release.yml on h5rdly/purecdp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file purecdp-0.8.0-py3-none-any.whl.

File metadata

  • Download URL: purecdp-0.8.0-py3-none-any.whl
  • Upload date:
  • Size: 406.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for purecdp-0.8.0-py3-none-any.whl
Algorithm Hash digest
SHA256 bb33b3bfc67d73c2ea393814f5b8c36249f652fa22c2bee556fcb578c1058086
MD5 e631610594f030060fc7adfbbbebb7d9
BLAKE2b-256 3fab1781e18bd1eadb82f4545e052db90c50f47b3c91f29287000da8793f8cf9

See more details on using hashes here.

Provenance

The following attestation bundles were made for purecdp-0.8.0-py3-none-any.whl:

Publisher: release.yml on h5rdly/purecdp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.8.0 This release

2 files

0.7.0

2 files

0.6.0

2 files

0.5.0

2 files

0.4.0

2 files

0.3.1

2 files

0.3.0

2 files

0.2.0

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page