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 andbrowser.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-inSyncBrowserwith noasync/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_clearancecookie 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 "dialog": above center
minimal_footprint=True, # trim the process down
show_browser_ui=False, # present as an app/dialog, not a browser
)
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_url /
app_page 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 the browser at an HTML file; its relative css/js/images load over
file://. app_page opens it as a chromeless app window, page as a tab:
spec = LaunchSpec(profile=..., headless=False, app_page=Path("ui/app.html"))
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=..., app_page=files("myapp") / "ui" / "app.html")
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, find_installed_extension
# local: an unpacked dir, or a copy from an installed browser
ext = ExtensionSpec(find_installed_extension("pejdijmoenmkgeppbflobdenhhabjlaj"))
# 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
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, app_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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file chauffeur-0.10.1.tar.gz.
File metadata
- Download URL: chauffeur-0.10.1.tar.gz
- Upload date:
- Size: 96.2 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
238ac658249aa3517f0c1e3c668f75d3b3a8b7498958a84a6a341538d60d485a
|
|
| MD5 |
efcdf4897c2a2fad1a4fe82065ab0728
|
|
| BLAKE2b-256 |
09c16d22050fb27eda61c8f89c396b69d6b3e7bc66611db4df31361281085134
|
File details
Details for the file chauffeur-0.10.1-py3-none-any.whl.
File metadata
- Download URL: chauffeur-0.10.1-py3-none-any.whl
- Upload date:
- Size: 33.5 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9f7c628a1505e228ac2ed235250ce94b5754919b4d8db2725ed5795f8fc8c869
|
|
| MD5 |
1c2aae24ca90230beb864f5d00b8fefa
|
|
| BLAKE2b-256 |
ac08da4437fd2163373d31ee9e8c9d9e0be7e1ef26c5182a8b60b4b5dd740c88
|