ODXProxy
SDKs

Python SDK

A typed, sync + async Python client for ODXProxy, built on httpx with HTTP/2 connection reuse.

terrakernel-odxproxyclient is the official Python client for ODXProxy. It exposes sync and async surfaces side by side (same constructor, same method names), is built on httpx with HTTP/2 connection reuse, and is fully PEP 561 typed.

Source: terrakernel/ODXProxyClient-Python · PyPI terrakernel-odxproxyclient · Python 3.12+ · v2 API since 0.9.0.

Install

pip install terrakernel-odxproxyclient

Import from terrakernel.odxproxyclient:

from terrakernel.odxproxyclient import ODXProxyClient

Construct a client, bind an instance

Create one ODXProxyClient per process (it owns an httpx.Client — the TCP/TLS pool is the main performance knob), then bind an Odoo instance with for_instance() to get a reusable Session. Per-instance details travel in every request body, so binding once and reusing the session is the canonical pattern.

from terrakernel.odxproxyclient import ODXProxyClient

with ODXProxyClient(
    "https://your-proxy.example.com",  # proxy base URL
    "<proxy x-api-key>",               # the PROXY's key
    default_timeout_secs=15,
) as client:
    erp = client.for_instance(
        url="https://erp.example.com",
        db="prod",
        user_id=2,
        api_key="<odoo user api key>",  # the Odoo USER's key
    )

    partners = erp.search_read(
        "res.partner",
        params=[[["is_company", "=", True]]],
        keyword={"fields": ["id", "name", "email"], "limit": 50, "context": {"tz": "UTC"}},
    )

Two different secrets

The client's api_key (2nd positional arg) is the proxy's x-api-key; for_instance(..., api_key=...) is the Odoo user's API key. They are never the same value — don't conflate them.

Methods return the JSON-RPC result directly (a JsonValue) and raise a typed exception on any error — there's no envelope to unwrap. params (a list) and keyword (a dict) are passed through to Odoo unchanged; you build the raw Odoo shapes yourself.

Async

AsyncODXProxyClient / AsyncSession mirror the sync API exactly — same args, same methods — just await the calls inside async with:

from terrakernel.odxproxyclient import AsyncODXProxyClient

async with AsyncODXProxyClient("https://your-proxy.example.com", "<proxy x-api-key>") as client:
    erp = client.for_instance(url="https://erp.example.com", db="prod", user_id=2, api_key="...")
    count = await erp.search_count("res.partner", params=[[["is_company", "=", True]]])

The 9 actions

Each allowed action is a method on the Session. All take keyword-only params, keyword, request_id, and timeout_secs (per-call override); call_method additionally takes fn_name positionally.

ActionMethod
search_countsession.search_count(model_id, *, params, keyword, ...)
searchsession.search(...)
readsession.read(...)
fields_getsession.fields_get(...)
search_readsession.search_read(...)
createsession.create(...)
writesession.write(...)
unlinksession.unlink(...)
call_methodsession.call_method(model_id, fn_name, *, params, keyword, ...)
new_id = erp.create("res.partner", params=[{"name": "Acme Inc", "is_company": True}])
erp.write("res.partner", params=[[new_id], {"name": "Acme LLC"}])
erp.unlink("res.partner", params=[[new_id]])

# call_method — fn_name is required and positional
erp.call_method("sale.order", "action_confirm", params=[[42]])

Calling call_method with an empty fn_name raises ValueError client-side (the proxy would otherwise return code -32002). Anything outside the 9 actions must go through call_method. Unlike some of the other SDKs, this client does not auto-strip pagination keys — pass only the keyword fields the action expects.

v2 sessions (Odoo 19+)

From 0.9.0 the client also speaks ODXProxy's v2 API (Odoo's JSON-2; needs ODXProxy 0.9.0+ and Odoo 19+). A v2 session comes from the same client with for_instance_v2 — no user_id, and an optional default context merged into every call (a call's own context= keys win):

erp2 = client.for_instance_v2(
    url="https://erp.example.com",
    db="prod",
    api_key="<odoo user api key>",   # an Odoo API key, not a password
    context={"lang": "en_US", "tz": "Asia/Jakarta", "allowed_company_ids": [1]},
)

partners = erp2.search_read("res.partner", [["is_company", "=", True]],
                            fields=["name", "email"], limit=20, order="name asc")
ids   = erp2.search("res.partner", [["customer_rank", ">", 0]], limit=10)   # list[int]
count = erp2.search_count("res.partner", [])                                # int
rows  = erp2.read("res.partner", ids, fields=["name"])
schema = erp2.fields_get("res.partner", attributes=["type", "string"])

new_ids = erp2.create("res.partner", [{"name": "Acme"}, {"name": "Globex"}])  # [41, 42]
one     = erp2.create_one("res.partner", {"name": "Initech"})                 # 43
erp2.write("res.partner", new_ids, {"comment": "via v2"})
erp2.unlink("res.partner", new_ids)

erp2.call_method("account.move", "action_post", ids=[7])                       # record method
erp2.call_method("res.partner", "name_search", kwargs={"name": "Acm", "limit": 5})
  • Named arguments only. There's no params or keyword: each argument goes to Odoo under its Python parameter name, and the domain is the list itself (no extra wrapping). Arguments left None are omitted, so Odoo's defaults apply.
  • create always returns a list of ids; create_one returns one int.
  • call_method takes ids= for record methods only, and every other argument in kwargs={...} under its Python name.
  • The async client has the same methods: aclient.for_instance_v2(...) returns an AsyncSessionV2.
  • client.supports_v2(url) checks an instance once and caches the answer; client.odoo_version_v2(url) returns {"version_info": [...], "version": "..."}.

Operational endpoints

These live on the client (not the session) and need no Odoo instance:

client.license()             # -> LicenseInfo(licensee, valid_until, is_valid)
client.about()               # -> BuildInfo(build, version)
client.metrics()             # -> str (Prometheus text)
client.odoo_version("https://erp.example.com")  # -> JsonValue

license() returns a typed LicenseInfo, not a JSON-RPC envelope — the proxy's /_/license endpoint emits a flat object. about, license, and metrics send no API key.

Typed errors

Every failure raises a subclass of ODXProxyError, one per proxy error code, so you can catch exactly what you need. Each carries .code, .message, .data, .http_status, and .request_id. See the full error catalog.

ExceptionProxy codeMeaning
AuthError-32000Missing or wrong x-api-key.
InvalidActionError-32001Action not in the allowlist.
MissingFnNameError-32002call_method without fn_name.
OdooTimeoutError-32003Upstream Odoo timed out.
OdooConnectError-32004Proxy couldn't reach Odoo.
InternalProxyError-32005Internal proxy error.
LicenseError0 (HTTP 403)Proxy integrity/license check failed.
OdooLogicErrorOdoo's code (HTTP 200)Odoo business error (validation, access rights).
TransportError—Local transport failure (DNS/TCP/TLS/local timeout) before the proxy replies.
Json2UnavailableError-32006v2: no JSON-2 on that Odoo (18 or older), or the database isn't selectable on that host.
InvalidRequestError-32007v2: invalid model/method name, or db/api_key not valid in an HTTP header.

Odoo-side errors that carry Odoo's HTTP status as the code — always on v2 — raise subclasses of OdooLogicError, so except OdooLogicError still catches them all. .odoo_error_name gives Odoo's exception class (e.g. "odoo.exceptions.ValidationError").

ExceptionCode (on HTTP 200)Meaning
OdooAuthError401Odoo rejected the Odoo API key (expired, revoked, wrong scope, or a password). Not AuthError, which is the proxy key.
OdooAccessError403Access rights, or a private method.
OdooNotFoundError404Unknown model/method, or a missing record.
OdooConflictError409Odoo lock conflict — the one worth retrying.
OdooValidationError422Validation error, or bad arguments.
OdooServerError5xxOdoo server error.
from terrakernel.odxproxyclient import AuthError, OdooLogicError, OdooTimeoutError, ODXProxyError

try:
    partners = erp.search_read("res.partner", params=[[]], keyword={"context": {"tz": "UTC"}})
except AuthError:
    reauth()                      # bad x-api-key
except OdooLogicError as e:
    show(e.message)               # Odoo validation / access error (arrived on HTTP 200)
except OdooTimeoutError:
    retry_with_backoff()
except ODXProxyError as e:
    log(e.code, e.message, e.http_status)

A JSON-RPC error can arrive with HTTP 200 (Odoo logic errors surface as OdooLogicError); the client inspects the envelope's error field for you and raises. Any error code outside the proxy's own catalog is treated as an Odoo pass-through (OdooLogicError). Code 0 is a LicenseError only on HTTP 403: Odoo 19+ also uses code 0 for its own v1 errors, and those raise OdooLogicError (fixed in 0.9.0).


Looking for another language? See the SDK overview — every client mirrors this same wire protocol.

On this page