Python client SDK for the ODXProxy reverse proxy (Odoo JSON-RPC 2.0 wire protocol). Sync + async, framework-friendly.
Project description
terrakernel-odxproxyclient
Python client SDK for ODXProxy — a reverse proxy that fronts one or more Odoo instances behind a unified JSON-RPC 2.0 API. By Terrakernel.
Importable as terrakernel.odxproxyclient (PEP 420 namespace under terrakernel).
- Sync and async, sharing one core. Pick the one that matches your runtime.
- HTTP/2 + brotli/gzip by default via
httpx. - Typed exceptions for every error code in the spec.
- Framework-friendly: drop-in patterns for FastAPI and Flask, but no framework dep.
orjsonfor fast JSON serialization.- Python
>=3.12(tested on 3.12 and 3.14). Wire protocol fully specified inSYSTEM_ARCHITECTURE.md.
Install
pip install terrakernel-odxproxyclient
# or, with uv
uv add terrakernel-odxproxyclient
Then import as:
from terrakernel.odxproxyclient import ODXProxyClient, AsyncODXProxyClient, OdooInstance
Two API keys, never confuse them
ODXProxy uses two completely separate credentials. The client mirrors this — they live in different places, intentionally:
| Key | Where it goes | What it is |
|---|---|---|
Proxy x-api-key |
On the client (api_key=... in the constructor) |
Sent as the x-api-key HTTP header. Authenticates you with the proxy. |
Odoo api_key |
On OdooInstance(api_key=...) |
Carried inside the JSON-RPC body. Authenticates the proxy with Odoo as a specific user. |
Quick start — sync
from terrakernel.odxproxyclient import ODXProxyClient, OdooInstance
with ODXProxyClient(
base_url="https://proxy.example.com",
api_key="PROXY_X_API_KEY",
) as client:
session = client.for_instance(
url="https://erp.example.com",
db="prod",
user_id=2,
api_key="ODOO_USER_API_KEY",
)
partners = session.search_read(
"res.partner",
params=[[["is_company", "=", True]]],
keyword={"fields": ["id", "name", "email"], "limit": 100},
)
print(partners)
Quick start — async
import asyncio
from terrakernel.odxproxyclient import AsyncODXProxyClient
async def main() -> None:
async with AsyncODXProxyClient(
base_url="https://proxy.example.com",
api_key="PROXY_X_API_KEY",
) as client:
session = client.for_instance(
url="https://erp.example.com",
db="prod",
user_id=2,
api_key="ODOO_USER_API_KEY",
)
partners = await session.search_read(
"res.partner",
params=[[["is_company", "=", True]]],
keyword={"fields": ["id", "name"], "limit": 100},
)
print(partners)
asyncio.run(main())
API reference
Client constructors
Sync and async clients share the same signature:
ODXProxyClient(
base_url: str,
api_key: str, # the proxy's x-api-key
*,
default_timeout_secs: float = 15.0,
http_client: httpx.Client | None = None, # async: httpx.AsyncClient
http2: bool = True,
)
If http_client is provided, the SDK uses it and will NOT close it on close()/aclose(). Otherwise the SDK owns an internal httpx.Client and closes it.
OdooInstance (frozen dataclass)
All fields are required:
OdooInstance(
url: str, # full Odoo URL, e.g. "https://erp.example.com"
db: str, # Odoo database name
user_id: int, # Odoo user id (not login)
api_key: str, # Odoo user's api_key — different from the proxy x-api-key
)
Session factories
Two equivalent ways to build a Session / AsyncSession:
# Canonical: build OdooInstance once, reuse across sessions.
session = client.session(OdooInstance(url=..., db=..., user_id=..., api_key=...))
# Shortcut for one-shot scripts.
session = client.for_instance(url=..., db=..., user_id=..., api_key=...)
Both return the same object. Sessions are cheap (no I/O on construction) — make as many as you need.
Action methods on Session / AsyncSession
All signatures are uniform. First positional arg is model_id (the Odoo model name, e.g. "res.partner"). params and keyword are keyword-only. params is forwarded to Odoo's execute_kw as positional args; keyword is forwarded as kwargs. The client never rewrites Odoo domains.
def search( model_id: str, *, params=None, keyword=None, request_id=None, timeout_secs=None) -> list[int]
def search_count( model_id: str, *, params=None, keyword=None, request_id=None, timeout_secs=None) -> int
def read( model_id: str, *, params=None, keyword=None, request_id=None, timeout_secs=None) -> list[dict]
def fields_get( model_id: str, *, params=None, keyword=None, request_id=None, timeout_secs=None) -> dict
def search_read( model_id: str, *, params=None, keyword=None, request_id=None, timeout_secs=None) -> list[dict]
def create( model_id: str, *, params=None, keyword=None, request_id=None, timeout_secs=None) -> int | list[int]
def write( model_id: str, *, params=None, keyword=None, request_id=None, timeout_secs=None) -> bool
def unlink( model_id: str, *, params=None, keyword=None, request_id=None, timeout_secs=None) -> bool
def call_method( model_id: str, fn_name: str, *, params=None, keyword=None, request_id=None, timeout_secs=None) -> JsonValue
Return types are not statically narrowed. The methods' declared return type is
JsonValue(None | bool | int | float | str | list | dict) because the proxy passes through whatever Odoo returns. The annotations above reflect what Odoo actually returns for each action — treat them as a guide, not a guarantee. If you need narrowing,assert isinstance(...)orcast(...)at the call site.
request_id=— optional client-supplied UUID; if omitted, the SDK generates a UUID4.timeout_secs=— per-call only. Sent as the proxy'sx-request-timeoutheader; this is the upstream Odoo timeout, not the local httpx timeout. Non-positive values are dropped (proxy default kicks in).
params / keyword shapes
params is wrapped to become positional args to Odoo's execute_kw(model, method, args, kwargs). The most common shapes:
# search / search_count / search_read: [[domain]]
session.search("res.partner", params=[[["is_company", "=", True]]])
# read: [ids, fields]
session.read("res.partner", params=[[1, 2, 3], ["name", "email"]])
# create: [vals]
session.create("res.partner", params=[{"name": "Acme"}])
# write: [ids, vals]
session.write("res.partner", params=[[1], {"name": "Acme Inc."}])
# unlink: [ids]
session.unlink("res.partner", params=[[1]])
# search_read with fields/limit/offset → use keyword
session.search_read(
"res.partner",
params=[[["is_company", "=", True]]],
keyword={"fields": ["id", "name"], "limit": 100, "offset": 0},
)
# call_method: positional args of the target Odoo method go in params
session.call_method("account.move", "action_post", params=[[invoice_id]])
If a shape isn't here, it matches whatever execute_kw expects — the spec is Odoo's, not the proxy's.
Ops endpoints on the client itself
client.about() -> BuildInfo(build: str, version: str)
client.license() -> LicenseInfo(licensee: str, valid_until: str, is_valid: bool)
# valid_until is "YYYY-MM-DD" (string, no tz)
client.metrics() -> str # Prometheus text format
client.odoo_version(url) -> JsonValue # Odoo's version banner (dict)
FastAPI integration
Create one AsyncODXProxyClient for the whole app, manage its lifetime with lifespan, and inject sessions via Depends. The single client owns the HTTP/2 connection pool — sharing it across requests is the whole performance story.
# app/main.py
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from fastapi import Depends, FastAPI, HTTPException, Request
from terrakernel.odxproxyclient import (
AsyncODXProxyClient,
AsyncSession,
AuthError,
ODXProxyError,
OdooInstance,
OdooLogicError,
)
ODOO = OdooInstance(
url="https://erp.example.com",
db="prod",
user_id=2,
api_key="ODOO_USER_API_KEY",
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.state.odx = AsyncODXProxyClient(
base_url="https://proxy.example.com",
api_key="PROXY_X_API_KEY",
default_timeout_secs=15.0,
)
try:
yield
finally:
await app.state.odx.aclose()
app = FastAPI(lifespan=lifespan)
async def get_session(request: Request) -> AsyncSession:
"""Inject a session bound to the default Odoo instance."""
return request.app.state.odx.session(ODOO)
@app.exception_handler(ODXProxyError)
async def odx_error_handler(_: Request, exc: ODXProxyError) -> HTTPException:
# Translate proxy/Odoo failures into HTTP errors your API wants to expose.
if isinstance(exc, AuthError):
raise HTTPException(status_code=502, detail="upstream auth failed")
if isinstance(exc, OdooLogicError):
raise HTTPException(status_code=400, detail=exc.message)
raise HTTPException(status_code=502, detail=exc.message)
@app.get("/partners")
async def list_partners(
limit: int = 50,
session: AsyncSession = Depends(get_session),
) -> list[dict]:
rows = await session.search_read(
"res.partner",
params=[[]],
keyword={"fields": ["id", "name", "email"], "limit": limit},
)
assert isinstance(rows, list)
return rows
@app.post("/invoices/{invoice_id}/post")
async def post_invoice(
invoice_id: int,
session: AsyncSession = Depends(get_session),
) -> dict:
await session.call_method("account.move", "action_post", params=[[invoice_id]])
return {"ok": True}
Notes
- One
AsyncODXProxyClientper process; never instantiate it per request. - If you talk to multiple Odoo databases, keep one client and call
client.session(other_instance)per request —Session/AsyncSessionis cheap and stateless. - For per-tenant credentials, build the
OdooInstancefrom request state (auth token, header, etc.) inside theDependsinstead of using a module constant.
Flask integration
The sync ODXProxyClient is thread-safe (it wraps a long-lived httpx.Client). Construct one at app-factory time and reuse it across requests; close it at process shutdown.
# app/__init__.py
import atexit
from flask import Flask, current_app, jsonify
from terrakernel.odxproxyclient import (
ODXProxyClient,
OdooInstance,
OdooLogicError,
ODXProxyError,
)
ODOO = OdooInstance(
url="https://erp.example.com",
db="prod",
user_id=2,
api_key="ODOO_USER_API_KEY",
)
def create_app() -> Flask:
app = Flask(__name__)
app.config.from_mapping(
ODX_BASE_URL="https://proxy.example.com",
ODX_API_KEY="PROXY_X_API_KEY",
)
odx = ODXProxyClient(
base_url=app.config["ODX_BASE_URL"],
api_key=app.config["ODX_API_KEY"],
default_timeout_secs=15.0,
)
app.extensions["odx"] = odx
atexit.register(odx.close)
@app.errorhandler(OdooLogicError)
def _odoo_logic(exc: OdooLogicError):
return jsonify(error=exc.message, code=exc.code), 400
@app.errorhandler(ODXProxyError)
def _odx(exc: ODXProxyError):
return jsonify(error=exc.message, code=exc.code), 502
@app.get("/partners")
def list_partners():
session = current_app.extensions["odx"].session(ODOO)
rows = session.search_read(
"res.partner",
params=[[]],
keyword={"fields": ["id", "name", "email"], "limit": 50},
)
return jsonify(rows)
@app.post("/invoices/<int:invoice_id>/post")
def post_invoice(invoice_id: int):
session = current_app.extensions["odx"].session(ODOO)
session.call_method("account.move", "action_post", params=[[invoice_id]])
return jsonify(ok=True)
return app
Why atexit instead of teardown_appcontext? The client owns a connection pool meant to live for the whole process. teardown_appcontext fires after every request and would defeat the pool. Close at process exit.
If you're running Flask with a worker model (gunicorn, uwsgi), each worker process gets its own client — that's correct and what you want.
Error handling
Every wire-level failure becomes a typed exception, all inheriting from ODXProxyError. Catch the base if you want one handler; catch a subclass if you want to react specifically.
from terrakernel.odxproxyclient import (
ODXProxyError,
AuthError, # -32000 / HTTP 401
InvalidActionError, # -32001 / HTTP 400
MissingFnNameError, # -32002 / HTTP 400
OdooTimeoutError, # -32003 / HTTP 504 (also: local httpx timeout)
OdooConnectError, # -32004 / HTTP 502 (also: local httpx connect error)
InternalProxyError, # -32005 / HTTP 500
LicenseError, # code 0 / HTTP 403
OdooLogicError, # any other code / HTTP 200 (Odoo pass-through)
TransportError, # unrecognized / malformed envelope
)
try:
session.write("res.partner", params=[[1], {"name": ""}])
except OdooLogicError as e:
# Odoo-side validation error — 200 OK with an error envelope.
print(e.code, e.message, e.data)
except OdooTimeoutError:
# Either the proxy reported -32003 OR the local httpx call timed out.
...
except ODXProxyError as e:
print(e.code, e.http_status, e.request_id)
HTTP 200 is not always success. The proxy returns Odoo-side logic errors as HTTP 200 with an
errorobject in the body. The client handles this for you —OdooLogicErrorwill be raised. Do not implement your own status-code checks; let the typed exceptions do the work.
Performance notes
- One client per process, always. The connection pool, HTTP/2 multiplexing, and TLS session reuse all live on the
httpx.Client/AsyncClientthat the proxy client owns. Throwing it away per request throws those away too. - HTTP/2 is on by default (
http2=True). Disable withhttp2=Falseif your proxy speaks only HTTP/1.1. - Response compression (
gzip,br,deflate) is negotiated automatically byhttpx— no SDK-side header juggling. orjsonis used for both serialization and deserialization. Significantly faster than stdlibjsonfor the kind of payloadssearch_readreturns.- No retries. This is deliberate. Wrap the client in your own retry policy (e.g.
tenacity) if you need one — the SDK won't silently re-fire calls that might have side effects.
Advanced: shared httpx client
If you already manage a process-wide httpx.Client / AsyncClient and want the SDK to use it, pass it in. The SDK will not close clients it doesn't own.
import httpx
from terrakernel.odxproxyclient import AsyncODXProxyClient
http = httpx.AsyncClient(base_url="https://proxy.example.com", http2=True, timeout=15.0)
odx = AsyncODXProxyClient(
base_url="https://proxy.example.com",
api_key="PROXY_X_API_KEY",
http_client=http,
)
# ... use odx ...
await odx.aclose() # does NOT close `http`
await http.aclose() # your responsibility
Testing
pip install -e ".[dev]"
# Unit tests (hermetic, respx-mocked, no network)
pytest -m "not integration"
# Integration tests against a real proxy
cp tests/integration/credentials.example.toml tests/integration/credentials.toml
$EDITOR tests/integration/credentials.toml
pytest -m integration
Integration tests are read-only by design — they exercise about, license, metrics, odoo_version, search_count, search_read, fields_get, plus a negative auth case. They never create, write, or unlink. The integration suite auto-skips when credentials.toml is absent, so a fresh clone runs only the unit tests.
License
MIT — see LICENSE. Copyright © 2026 Terrakernel.
Project details
Release history Release notifications | RSS feed
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 terrakernel_odxproxyclient-0.1.0.tar.gz.
File metadata
- Download URL: terrakernel_odxproxyclient-0.1.0.tar.gz
- Upload date:
- Size: 23.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ef99bff90474bf4e61e07ec0e0597ee4134eea7da8c0a03d77c83e62728a882f
|
|
| MD5 |
c38ab87ca4d29bdb9e6c18c977e215e2
|
|
| BLAKE2b-256 |
a6cc0251cb5f14f0dd9423552a77e70cb3a3f0029b8279797857400d0fc6c29e
|
File details
Details for the file terrakernel_odxproxyclient-0.1.0-py3-none-any.whl.
File metadata
- Download URL: terrakernel_odxproxyclient-0.1.0-py3-none-any.whl
- Upload date:
- Size: 20.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8b2da83063f9e2bd40ca8ef76a373ad67c3fd6d247aadf9c82a7eb22b9bd19c1
|
|
| MD5 |
15390e8bf4f76ca3752f9fa02b38aca0
|
|
| BLAKE2b-256 |
ad1e4682f7fdfe722e8272480c8d64730f6912e19eaf98a4f5cd07f0ca8995d3
|