Skip to main content

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.
  • orjson for fast JSON serialization.
  • Python >=3.12 (tested on 3.12 and 3.14). Wire protocol fully specified in SYSTEM_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(...) or cast(...) 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's x-request-timeout header; 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 AsyncODXProxyClient per 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/AsyncSession is cheap and stateless.
  • For per-tenant credentials, build the OdooInstance from request state (auth token, header, etc.) inside the Depends instead 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 error object in the body. The client handles this for you — OdooLogicError will 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 / AsyncClient that the proxy client owns. Throwing it away per request throws those away too.
  • HTTP/2 is on by default (http2=True). Disable with http2=False if your proxy speaks only HTTP/1.1.
  • Response compression (gzip, br, deflate) is negotiated automatically by httpx — no SDK-side header juggling.
  • orjson is used for both serialization and deserialization. Significantly faster than stdlib json for the kind of payloads search_read returns.
  • 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


Download files

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

Source Distribution

terrakernel_odxproxyclient-0.1.0.tar.gz (23.8 kB view details)

Uploaded Source

Built Distribution

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

terrakernel_odxproxyclient-0.1.0-py3-none-any.whl (20.1 kB view details)

Uploaded Python 3

File details

Details for the file terrakernel_odxproxyclient-0.1.0.tar.gz.

File metadata

File hashes

Hashes for terrakernel_odxproxyclient-0.1.0.tar.gz
Algorithm Hash digest
SHA256 ef99bff90474bf4e61e07ec0e0597ee4134eea7da8c0a03d77c83e62728a882f
MD5 c38ab87ca4d29bdb9e6c18c977e215e2
BLAKE2b-256 a6cc0251cb5f14f0dd9423552a77e70cb3a3f0029b8279797857400d0fc6c29e

See more details on using hashes here.

File details

Details for the file terrakernel_odxproxyclient-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for terrakernel_odxproxyclient-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8b2da83063f9e2bd40ca8ef76a373ad67c3fd6d247aadf9c82a7eb22b9bd19c1
MD5 15390e8bf4f76ca3752f9fa02b38aca0
BLAKE2b-256 ad1e4682f7fdfe722e8272480c8d64730f6912e19eaf98a4f5cd07f0ca8995d3

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