Skip to main content

🚗 chauffeur

A Python control plane for a local Chromium browser you launch and own.

PyPI CI License: MIT Python 3.12+ Platforms: macOS · Linux

chauffeur is a control plane, not an automation framework. You decide how the browser spins up, patch and load extensions, and talk to it in both directions with a decorator-based command API.

Why chauffeur?

Reach for it when you want a real browser engine under Python control, without a selector-based automation framework or an Electron-sized bundle. Things people build with it:

  • Desktop-style apps with a web UI. Ship an HTML/CSS/JS front end backed by Python in a chromeless window, using the browser already on the machine: a local dashboard, a media organizer, a password vault.
  • Tools that reuse your real logged-in session. Sign in once in a headed window, then run headless against the same profile with the cookies and User-Agent intact, including sites behind Cloudflare or an MFA wall.
  • Extension harnesses. Pull an extension from the Web Store or a local dir, patch it, load it, and drive or observe it from Python for testing or to add behavior.
  • Browser-backed jobs. Render pages, run real JavaScript, or reach web APIs from a genuine engine, orchestrated by Python instead of a headless HTTP client.
  • Human-in-the-loop flows. Open a window for someone to sign in or approve something, then take back over programmatically.

Features

  • 🚀 Launch your way. Headless or headed, chromeless app windows, any installed Chromium (Chrome, Chromium, Brave, Edge). A dedicated profile with guardrails against clobbering your real one.
  • 🔌 Bidirectional commands. py_chauffeur.call(...) into Python and browser.call(...) into JS over one JSON envelope, with dataclass validation and error replies that never hang.
  • 🧵 Async or sync. The async Browser, or a drop-in SyncBrowser with no async/await.
  • 📄 Local pages, no server. Show an HTML file (with its css/js) over file://, including UIs packaged inside a wheel.
  • 🧩 Extension patching. Take a local dir or pull one from the Chrome Web Store, inject config, rewrite or add files, load over CDP.
  • 🕵️ User-Agent capture and replay. Keep a cf_clearance cookie valid across headless runs.
  • 🪶 Tiny. One runtime dependency (websockets) and no daemon.

Install

uv add chauffeur   # or: pip install chauffeur

Requires Python 3.12+ and a Chromium-family browser (Chrome, Chromium, Brave, or Edge). macOS and Linux.

Quickstart

Register a Python command, then have the browser call it and get a reply:

import asyncio
from pathlib import Path
from chauffeur import Browser, LaunchSpec


async def main():
    browser = Browser(LaunchSpec(profile=Path("~/.myapp/profile")))

    # the page can call this via py_chauffeur.call("greet", ...)
    @browser.command()
    def greet(params: dict) -> str:
        return f"Hello, {params['name']}!"

    async with browser:
        reply = await browser.evaluate(
            "py_chauffeur.call('greet', {name: 'world'})"
        )
        print(reply)  # -> Hello, world!


asyncio.run(main())

Launch a browser your way

from pathlib import Path
from chauffeur import Browser, LaunchSpec, Window

spec = LaunchSpec(
    profile=Path("~/.myapp/profile"),   # dedicated profile you own (required)
    browser="auto",                     # or "chrome" / a binary Path
    headless=True,
    devtools_port=0,                    # 0 = pick a free port
    window=Window(size=(390, 320), position="center"),  # or "top" / "dialog" (above center)
    minimal_footprint=True,             # trim the process down
    show_browser_ui=False,              # present as an app/dialog, not a browser
)

async with Browser(spec) as browser:    # launches here; exiting closes it
    await browser.navigate("https://example.com")

Entering the async with block launches the browser, connects over CDP, and installs the py_chauffeur channel; leaving it shuts everything down. If you only want the process — no channel, no CDP client — bare launch(spec) returns a BrowserHandle with the DevTools port and a terminate() method.

The profile is required on purpose: there is no default, so a launch can never silently land on the browser profile you use daily. Pointing it at a real user data dir works but must be deliberate, and it logs a warning, since chauffeur opens a debugging port on it and rewrites its Preferences.

Headed windows start clean by default, with no bookmarks bar or startup clutter, so a window reads as an app or dialog rather than a browser. Pass show_browser_ui=True for Chrome's normal browsing UI, or use app=True for a fully chromeless window.

Talk to the browser both ways

The browser calls into Python with py_chauffeur.call(...); Python calls into the browser with browser.call(...). Same JSON envelope in both directions.

from dataclasses import dataclass, field
from chauffeur import Browser, LaunchSpec

browser = Browser(spec)

@dataclass
class SavePassword:
    url: str
    username: str
    secret: str
    tags: list[str] = field(default_factory=list)

@dataclass
class SaveResult:
    ok: bool
    entry_id: str

@browser.command()                       # name defaults to "save_password"
async def save_password(params: SavePassword) -> SaveResult:
    entry = await vault.store(params.url, params.username, params.secret)
    return SaveResult(ok=True, entry_id=entry.id)

@browser.command("get_config")
def get_config(params: dict):            # annotate with dict for the raw payload
    return {"theme": "dark"}

@browser.on("Page.frameNavigated")       # raw CDP events stay dicts
async def navigated(event: dict):
    print("now at", event["frame"]["url"])

async def main():
    async with browser:
        # Python -> browser
        await browser.call("refresh_ui", {"section": "vault"})
        await browser.serve()            # block until the browser closes

Prefer no async/await? SyncBrowser is a drop-in synchronous facade over the same core (it runs the event loop on a background thread). Every method loses its a-prefix (browser.evaluate(...), browser.call(...), browser.serve()), and @command/@on handlers still work. They run on the loop thread, so keep them quick.

Browser side (injected py_chauffeur global is available in every document):

const res = await py_chauffeur.call("save_password", {url, username, secret});
py_chauffeur.notify("telemetry", {event: "unlock"});   // fire-and-forget, no reply
py_chauffeur.on("refresh_ui", async ({section}) => { /* handles browser.call() */ });

Annotate a handler's params with a dataclass and you get a validated dataclass; annotate it with dict (or leave it off) and you get the raw payload. Dataclass return values are serialized back automatically. Unknown commands, bad params, and handler exceptions always produce an error reply so a await py_chauffeur.call(...) never hangs.

Show a local page, no server

Point url at an HTML file (a Path); its relative css/js/images load over file://. Add app=True for a chromeless app window, or omit it for a tab:

spec = LaunchSpec(profile=..., headless=False, url=Path("ui/app.html"), app=True)

Packaged UIs work the same way: pass an importlib.resources traversable and chauffeur extracts it (siblings included) for the browser's lifetime, even from a zipped install:

from importlib.resources import files

spec = LaunchSpec(profile=..., url=files("myapp") / "ui" / "app.html", app=True)

With Browser, the page is navigated only after the py_chauffeur channel is installed, so its scripts can call py_chauffeur.on(...) / py_chauffeur.notify(...) from their first line.

Patch and load an extension

An ExtensionSpec describes a source and the patches to apply. The source is a local unpacked directory or an id pulled from the Chrome Web Store; both take the same patches. Hand the spec to LaunchSpec.extensions and the launch builds it for you (call build_extension(spec, workdir) yourself only if you want the dir directly).

from chauffeur import ExtensionSpec

# local: any unpacked extension directory (packaged with your app or on disk)
ext = ExtensionSpec("path/to/unpacked-extension")

# or pull it from the Web Store by id (downloaded once, cached). refresh=True
# re-downloads on each launch and keeps the cache when the store is unreachable;
# cache_dir= pins the pristine download to a fixed location
ext = ExtensionSpec.from_store("pejdijmoenmkgeppbflobdenhhabjlaj", refresh=True)

ext = (
    ext
    .inject_config("background.js", {"port": 8765, "token": "..."})  # prepend a config global
    .append("background.js", bridge_js)                            # modify an existing file
    .add_file("content/inject.js", inject_js)                      # add a new file
    .patch_manifest(lambda m: {**m, "name": m["name"] + " (patched)"})
)
spec = LaunchSpec(profile=..., extensions=(ext,))

The build lands beside the profile (<profile>.extensions/<name>), so one path anchors all of the app's browser state with nothing to configure twice, and it is rebuilt on every launch so a bumped installed version is picked up automatically. Loading happens over CDP (Extensions.loadUnpacked, ids on browser.extension_ids) because branded Chrome 137+ silently ignores --load-extension. Extensions therefore load when driving the browser through Browser, not bare launch().

Replay a captured User-Agent (Cloudflare)

Headless Chromium sends a HeadlessChrome/x.y UA that Cloudflare rejects, and a cf_clearance cookie earned in a headed login is bound to the exact UA that session sent. Capture the real UA during login, then replay it on headless runs:

# 1. Headed login: capture the real UA once the user is signed in.
login = LaunchSpec(profile=profile, headless=False, url="https://example.com/login")
async with Browser(login) as browser:
    await wait_until_signed_in(browser)
    await browser.capture_user_agent()      # writes <profile>.ua

# 2. Headless runs: replay it automatically.
work = LaunchSpec(profile=profile, headless=True, user_agent="auto")
async with Browser(work) as browser:
    ...                                      # same profile, same UA, cookie stays valid

user_agent="auto" replays the cached UA (Headless marker stripped) on headless launches only; headed browsers send their real UA. Pass an explicit string to force one verbatim, or leave it None (default) to not touch the UA at all. If nothing was captured, replay falls back to a per-platform reconstruction, so a missing capture never breaks a launch.

Examples

Runnable, self-contained scripts live in examples/, from a one-liner launch to a live-updating packaged UI and Web Store extension patching. Run any with uv run examples/<name>/main.py.

Contributing

Contributions are welcome. The project uses uv for everything. Set up a checkout:

git clone https://github.com/michel-tricot/chauffeur
cd chauffeur
uv sync --dev            # runtime + dev tools into .venv

Day-to-day commands:

uv run pytest            # tests (no real browser needed; the CDP layer is faked)
uv run ruff check .      # lint (add --fix to autofix)
uv run ty check          # type check (chauffeur/ only)
uv run deptry .          # dependency check

Lint, types, and tests run in CI across Python 3.12 to 3.14 and must pass before a change lands. Run an example against a real browser:

uv run examples/01_headless_launch_and_evaluate/main.py

Work on the docs (MkDocs Material):

uv sync --group docs
uv run mkdocs serve      # live preview at http://127.0.0.1:8000

If you change public API, usage, or the pitch, update the README and the matching page under docs/ in the same PR; the API reference is generated from docstrings. See AGENTS.md for architecture notes, conventions, and the release process.

License

MIT © Michel Tricot

Download files

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

Source Distribution

chauffeur-0.13.1.tar.gz (107.0 kB view details)

Uploaded Source

Built Distribution

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

chauffeur-0.13.1-py3-none-any.whl (40.8 kB view details)

Uploaded Python 3

File details

Details for the file chauffeur-0.13.1.tar.gz.

File metadata

  • Download URL: chauffeur-0.13.1.tar.gz
  • Upload date:
  • Size: 107.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for chauffeur-0.13.1.tar.gz
Algorithm Hash digest
SHA256 7918db5e27bb390086f7d3845821c8a4e374ff582a669ed48cff24d41e2387fc
MD5 e33518168538cfbc6e997d2fd2553d82
BLAKE2b-256 6690daad71755bbd1ce0c668bee80fc048016198d7471b3f0775161b1b4b5760

See more details on using hashes here.

File details

Details for the file chauffeur-0.13.1-py3-none-any.whl.

File metadata

  • Download URL: chauffeur-0.13.1-py3-none-any.whl
  • Upload date:
  • Size: 40.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for chauffeur-0.13.1-py3-none-any.whl
Algorithm Hash digest
SHA256 a4aa806660fafc0d5c05a19625a36cef2faadc6a47bff15f0aa27c555c7f172d
MD5 bae36b2dfe561e42e856740fa92e9e73
BLAKE2b-256 74ed93a8a65a7d06abd78113199eb26e7e5a32c53a2751bd217f3e48211502be

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page