Centralized browser session orchestration framework built on Playwright and FastAPI
Project description
Orcheems
Centralized browser session orchestration for Playwright + FastAPI.
Orcheems solves one problem and solves it well: managing shared browser sessions across concurrent tasks without login conflicts, resource leaks, or race conditions. Built for internal automation services that need to stay alive under load.
pip install orcheems
---
git clone https://github.com/ZesperII/Orcheems.git
Why Orcheems
Running multiple Playwright tasks against the same authenticated site is harder than it looks. Naive implementations either login on every request (slow, rate-limited) or share browser contexts between tasks (race conditions, session corruption). Orcheems sits in the middle: one session per credential, one task at a time per session, automatic cookie reuse, and a TTL watcher that cleans up idle contexts before they leak RAM.
The core idea: tasks declare what they want to do with a page. Orcheems handles everything else.
How it works
Three layers, each with a single responsibility:
BrowserManager — one shared Chromium process per worker
└── SessionManager — one context per credential, PENDING → READY ↔ LOCKED
└── BaseTask — where you write business logic, nothing else
Session states:
| State | Meaning |
|---|---|
PENDING |
Login in progress — slot reserved, all requests rejected |
READY |
Idle, available for the next task |
LOCKED |
Task running — no concurrent access allowed |
When a task calls with_page(), Orcheems logs in if needed, locks the session, runs your code, then releases the lock. If another request arrives while the session is LOCKED, it gets a 409 immediately — no queuing, no silent waiting, no corrupted state.
Installation
pip install orcheems
# Install Playwright browsers after
playwright install chromium
Requirements: Python 3.12+
Quickstart
1. Implement a login service for your site
# app/sites/vnpt.py
from orcheems import BaseLoginService, SiteLoginServiceRegister
from orcheems.login.base import cookie_incomplete_handler
from playwright.async_api import BrowserContext, Page
@SiteLoginServiceRegister.register
class VNPTLoginService(BaseLoginService):
SITE = "vnpt"
async def _perform_login(
self,
page: Page,
context: BrowserContext,
credential,
) -> Page:
await page.goto(credential.base_url, wait_until="networkidle")
await page.fill("#UserName", credential.data["username"])
await page.fill("#Password", credential.data["password"])
await page.click("button[type='submit']")
return page
async def _is_session_valid(self, page: Page) -> bool:
try:
return await page.wait_for_selector("#logted", timeout=5000) is not None
except Exception:
return False
Two methods to implement — that's it. _perform_login runs your login steps. _is_session_valid checks whether the resulting page is actually authenticated.
2. Write a task
# app/tasks/invoice.py
from orcheems import BaseTask, Credential, task_registration
from fastapi import APIRouter
from pydantic import BaseModel
@task_registration(prefix="/vnpt", tags=["vnpt"])
class InvoiceDownloadTask(BaseTask):
def register_route(self, router: APIRouter):
class Body(BaseModel):
credential: Credential
invoice_id: str
@router.post("/download")
async def download(body: Body):
result = await self.with_page(
body.credential,
lambda page: self._fetch_invoice(page, body.invoice_id),
using_state=True, # try saved cookies first
ttl_seconds=120, # keep context alive for 2 min after task
)
return {"status": "ok", "data": result}
async def _fetch_invoice(self, page, invoice_id: str):
await page.goto(f"/invoices/{invoice_id}")
return await page.inner_text(".invoice-total")
3. Add auto-discovery to each app package
import app.sites only runs app/sites/__init__.py — it does not
automatically import vnpt.py, wfx.py, or any other file inside the
package. Without auto-discovery, the decorators in those files never run
and both registries stay empty.
Add this to app/sites/__init__.py and app/tasks/__init__.py:
# app/sites/__init__.py (repeat identically for app/tasks/__init__.py)
import importlib
import pkgutil
from pathlib import Path
for _, module_name, _ in pkgutil.iter_modules([str(Path(__file__).parent)]):
importlib.import_module(f"{__name__}.{module_name}")
Now import app.sites triggers every @SiteLoginServiceRegister.register
in the package, and import app.tasks triggers every @task_registration.
Adding a new site or task is just adding a new file — no other changes needed.
4. Wire everything together
# main.py
import app.sites # triggers @SiteLoginServiceRegister.register
import app.tasks # triggers @task_registration(...)
from orcheems import Orcheemstrator
from orcheems.storage import RedisStateStorage
from fastapi.middleware.cors import CORSMiddleware
operator = Orcheemstrator(
state_storage=RedisStateStorage(), # or LocalStateStorage(".cookies")
)
app = operator.auto_register_and_build()
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
uvicorn main:app --reload
Credential identity
Orcheems identifies accounts using UUIDv5 derived deterministically from (site, base_url, data):
from orcheems import Credential
credential = Credential(
site = "vnpt",
base_url = "https://example-tt78.vnpt-invoice.com.vn/",
data = {"username": "admin", "password": "secret"},
)
print(credential.credential_id)
# → "3f2a1b4c-..." — always the same for the same input
Same credential object from any client always maps to the same session slot. No external ID management needed.
Session lifecycle
Cookie reuse (bypass login)
Pass using_state=True to attempt login via saved cookies before triggering a full browser login:
result = await self.with_page(
credential,
lambda page: do_work(page),
using_state=True,
)
If the saved state is invalid, Orcheems falls back to full login automatically.
Multi-step cookie recovery
Some sites require an extra step (OTP, captcha re-entry) when cookies are partially valid. Use @cookie_incomplete_handler:
from orcheems.login.base import cookie_incomplete_handler
class MyLoginService(BaseLoginService):
SITE = "mysite"
@cookie_incomplete_handler
async def handle_otp(self, context, page, credential):
await page.fill("#otp", credential.data["otp"])
await page.click("#submit")
return page
async def _perform_login(self, page, context, credential) -> Page:
...
async def _is_session_valid(self, page) -> bool:
...
Keep-alive and TTL
By default, the browser context is closed immediately after a task completes. Use keep_alive or ttl_seconds to hold it open for reuse:
# Keep alive indefinitely until manually closed or server restart
await self.with_page(credential, work, keep_alive=True)
# Keep alive for 90 seconds, then auto-close
await self.with_page(credential, work, ttl_seconds=90)
The TTL watcher runs every 5 seconds in the background and only closes READY sessions — it never interrupts a running task.
SSE streaming
For long-running tasks, use with_page_stream() to push progress events back to the client:
@router.post("/crawl")
async def crawl(body: Body):
async def work(page, emit):
await emit("progress", {"step": "navigating"})
await page.goto("/data")
await emit("progress", {"step": "extracting"})
rows = await page.query_selector_all("tr")
return {"count": len(rows)}
return self.with_page_stream(body.credential, work, using_state=True)
Client receives a stream of newline-delimited JSON events:
data: {"type": "progress", "data": {"step": "navigating"}}
data: {"type": "progress", "data": {"step": "extracting"}}
data: {"type": "done", "data": {"count": 42}}
Storage backends
from orcheems.storage import LocalStateStorage, RedisStateStorage
# Local files — good for development
LocalStateStorage(".cookies") # layout: .cookies/{site}/{credential_id}.json
# Redis — recommended for production
RedisStateStorage() # reads REDIS_URL from environment
RedisStateStorage("redis://localhost:6379/0", ttl_seconds=10800)
Implement BaseStateStorage to add your own backend (S3, database, etc.).
Management API
Orcheems mounts a built-in management router on every app:
| Method | Path | Description |
|---|---|---|
GET |
/health |
Liveness check + storage status |
GET |
/sessions |
List all active sessions |
POST |
/sessions/status |
Check one session by Credential |
DELETE |
/sessions/{credential_id} |
Force-close a READY session |
Guard pattern — call /sessions/status before sending a task request to detect conflicts cheaply, before any browser resource is allocated:
POST /sessions/status
{"site": "vnpt", "base_url": "https://...", "data": {...}}
# 200 → {"action": "proceed", "ready": true}
# 409 → {"action": "wait", "ready": false} # LOCKED or PENDING
# 404 → {"action": "login_required", "ready": false} # no session yet
Manual registration
Auto-discovery via auto_register_and_build() is the recommended pattern, but you can register tasks manually:
from orcheems import Orcheemstrator
from orcheems.storage import LocalStateStorage
from app.tasks.invoice import InvoiceDownloadTask
from app.tasks.stock import StockTask
app = (
Orcheemstrator(state_storage=LocalStateStorage(".cookies"))
.register_task(InvoiceDownloadTask(), prefix="/invoice", tags=["invoice"])
.register_task(StockTask(), prefix="/stock", tags=["stock"])
.build()
)
Project layout
your-project/
├── orcheems/ # the framework — don't edit
├── app/
│ ├── sites/
│ │ ├── __init__.py # auto-discovers all site modules
│ │ ├── vnpt.py # @SiteLoginServiceRegister.register
│ │ └── wfx.py
│ └── tasks/
│ ├── __init__.py # auto-discovers all task modules
│ └── invoice.py # @task_registration(...)
├── main.py # entry point
└── pyproject.toml
License
MIT
Project details
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 orcheems-0.1.3.tar.gz.
File metadata
- Download URL: orcheems-0.1.3.tar.gz
- Upload date:
- Size: 36.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.0 {"installer":{"name":"uv","version":"0.11.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
47f1028ad30781a9d705d2954c63534ae34c7ae20197df4688887838ac878351
|
|
| MD5 |
2461ed642c823e30b9d2dae4ac755105
|
|
| BLAKE2b-256 |
48de26eff6e1e603a68490ac400be76bc8f624d59c92dc01662e8a03ebac523b
|
File details
Details for the file orcheems-0.1.3-py3-none-any.whl.
File metadata
- Download URL: orcheems-0.1.3-py3-none-any.whl
- Upload date:
- Size: 43.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.0 {"installer":{"name":"uv","version":"0.11.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":null,"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
42073526e7eb433c709f73b8ca7cd7fb7ff04e9d79f7f8239b87de12781833aa
|
|
| MD5 |
de98808bb6527fbde5007d327072145b
|
|
| BLAKE2b-256 |
ec173ec1bf30cebb65138930f1e107ebcbb4ad78e0819f0a6e289e24ca0edf75
|