Skip to content

OdooClient

The main entry point for interacting with Odoo. Domain operations are available as namespace properties on the client instance.

write(..., check_write_date=expected) offers a best-effort, non-atomic freshness preflight, not compare-and-set. Changes after the read or within the same second can be missed. See freshness checks and errors.

Domain Namespaces

Property Class Description
client.helpdesk HelpdeskNamespace Helpdesk ticket operations
client.crm CRMNamespace CRM lead/opportunity operations
client.tasks TaskNamespace Project task operations
client.projects ProjectNamespace Project operations
client.knowledge KnowledgeNamespace Knowledge article operations
client.timer TimerNamespace Timer and timesheet management
client.security SecurityNamespace Security group management
client.generic GenericNamespace Generic model operations
from vodoo import OdooClient, OdooConfig

config = OdooConfig(url="https://my.odoo.com", database="mydb",
                    username="bot@example.com", password="api-key")

with OdooClient(config) as client:
    tickets = client.helpdesk.list(limit=10)
    client.helpdesk.comment(42, "fixed")

    leads = client.crm.list(limit=5)
    tasks = client.tasks.list(limit=5)

OdooClient

OdooClient(config: OdooConfig, *, transport: OdooTransport | None = None, auto_detect: bool = True)

Odoo client for external API access.

Wraps an OdooTransport to provide a convenient interface for Odoo operations. Supports both legacy JSON-RPC (Odoo 14-18) and JSON-2 API (Odoo 19+).

Initialize Odoo client.

PARAMETER DESCRIPTION
config

Odoo configuration

TYPE: OdooConfig

transport

Explicit transport instance (skips auto-detection)

TYPE: OdooTransport | None DEFAULT: None

auto_detect

If True and no transport given, probe JSON-2 first then fall back to legacy. If False, use legacy directly.

TYPE: bool DEFAULT: True

Source code in src/vodoo/client.py
def __init__(
    self,
    config: OdooConfig,
    *,
    transport: OdooTransport | None = None,
    auto_detect: bool = True,
) -> None:
    """Initialize Odoo client.

    Args:
        config: Odoo configuration
        transport: Explicit transport instance (skips auto-detection)
        auto_detect: If True and no transport given, probe JSON-2 first then
                     fall back to legacy. If False, use legacy directly.
    """
    self.config = config
    self.url = config.url.rstrip("/")
    self.db = config.database
    self.username = config.username
    self.password = config.password
    self._retry = config.retry_config
    self._extra_headers = config.http_headers

    if transport is not None:
        self._transport = transport
    elif auto_detect:
        self._transport = self._detect_transport()
    else:
        self._transport = LegacyTransport(
            url=self.url,
            database=self.db,
            username=self.username,
            password=self.password,
            retry=self._retry,
            extra_headers=self._extra_headers,
        )

    # Domain namespaces
    self.helpdesk = _make_helpdesk(self)
    self.crm = _make_crm(self)
    self.tasks = _make_tasks(self)
    self.projects = _make_projects(self)
    self.account_moves = _make_account_moves(self)
    self.activities = _make_activities(self)
    self.documents = _make_documents(self)
    self.knowledge = _make_knowledge(self)
    self.timer = _make_timer(self)
    self.security = _make_security(self)
    self.generic = _make_generic(self)

transport property

transport: OdooTransport

The underlying transport.

is_json2 property

is_json2: bool

Whether the client is using the JSON-2 API (Odoo 19+).

uid property

uid: int

Get authenticated user ID.

close

close() -> None

Close the underlying HTTP client.

Source code in src/vodoo/client.py
def close(self) -> None:
    """Close the underlying HTTP client."""
    self._transport.close()

execute

execute(model: str, method: str, *args: Any, **kwargs: Any) -> Any

Execute a method on an Odoo model.

PARAMETER DESCRIPTION
model

Odoo model name (e.g., 'helpdesk.ticket')

TYPE: str

method

Method name (e.g., 'search', 'read')

TYPE: str

*args

Positional arguments for the method

TYPE: Any DEFAULT: ()

**kwargs

Keyword arguments for the method

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
Any

Method result

Source code in src/vodoo/client.py
def execute(
    self,
    model: str,
    method: str,
    *args: Any,
    **kwargs: Any,
) -> Any:
    """Execute a method on an Odoo model.

    Args:
        model: Odoo model name (e.g., 'helpdesk.ticket')
        method: Method name (e.g., 'search', 'read')
        *args: Positional arguments for the method
        **kwargs: Keyword arguments for the method

    Returns:
        Method result
    """
    return self._transport.execute_kw(model, method, list(args), kwargs or None)

execute_with_user_context

execute_with_user_context(model: str, method: str, user_id: int, *args: Any, **kwargs: Any) -> Any

Execute with a sudo_user_id context hint.

This does not change the authenticated identity or access checks by itself. It only has an effect when server-side code explicitly interprets the context key.

Source code in src/vodoo/client.py
def execute_with_user_context(
    self,
    model: str,
    method: str,
    user_id: int,
    *args: Any,
    **kwargs: Any,
) -> Any:
    """Execute with a ``sudo_user_id`` context hint.

    This does not change the authenticated identity or access checks by itself.
    It only has an effect when server-side code explicitly interprets the context key.
    """
    context = dict(kwargs.get("context") or {})
    context["sudo_user_id"] = user_id
    kwargs["context"] = context
    return self.execute(model, method, *args, **kwargs)

execute_sudo

execute_sudo(model: str, method: str, user_id: int, *args: Any, **kwargs: Any) -> Any

Backward-compatible alias for :meth:execute_with_user_context.

Despite the historical name, this does not impersonate another user or bypass access checks unless custom server-side code honors sudo_user_id.

Source code in src/vodoo/client.py
def execute_sudo(
    self,
    model: str,
    method: str,
    user_id: int,
    *args: Any,
    **kwargs: Any,
) -> Any:
    """Backward-compatible alias for :meth:`execute_with_user_context`.

    Despite the historical name, this does not impersonate another user or bypass
    access checks unless custom server-side code honors ``sudo_user_id``.
    """
    return self.execute_with_user_context(model, method, user_id, *args, **kwargs)

search

search(model: str, domain: list[Any] | None = None, limit: int | None = None, offset: int = 0, order: str | None = None) -> list[int]

Search for records.

Source code in src/vodoo/client.py
def search(
    self,
    model: str,
    domain: list[Any] | None = None,
    limit: int | None = None,
    offset: int = 0,
    order: str | None = None,
) -> list[int]:
    """Search for records."""
    return self._transport.search(model, domain, limit, offset, order)

read

read(model: str, ids: list[int], fields: list[str] | None = None) -> list[dict[str, Any]]

Read records by IDs.

Source code in src/vodoo/client.py
def read(
    self,
    model: str,
    ids: list[int],
    fields: list[str] | None = None,
) -> list[dict[str, Any]]:
    """Read records by IDs."""
    return _normalize_false(self._transport.read(model, ids, fields))

search_read

search_read(model: str, domain: list[Any] | None = None, fields: list[str] | None = None, limit: int | None = None, offset: int = 0, order: str | None = None) -> list[dict[str, Any]]

Search and read records in one call.

Source code in src/vodoo/client.py
def search_read(
    self,
    model: str,
    domain: list[Any] | None = None,
    fields: list[str] | None = None,
    limit: int | None = None,
    offset: int = 0,
    order: str | None = None,
) -> list[dict[str, Any]]:
    """Search and read records in one call."""
    return _normalize_false(
        self._transport.search_read(model, domain, fields, limit, offset, order)
    )

create

create(model: str, values: dict[str, Any], context: dict[str, Any] | None = None) -> int

Create a new record.

Source code in src/vodoo/client.py
def create(
    self,
    model: str,
    values: dict[str, Any],
    context: dict[str, Any] | None = None,
) -> int:
    """Create a new record."""
    return self._transport.create(model, process_values(values), context)

write

write(model: str, ids: list[int], values: dict[str, Any], *, check_write_date: str | None = None) -> bool

Update records, optionally checking freshness immediately beforehand.

check_write_date is a best-effort, non-atomic preflight: another writer can change records between the read and write, and same-second changes can be invisible. All IDs must match the supplied timestamp. An unusable or stale observation prevents the mutation attempt.

Source code in src/vodoo/client.py
def write(
    self,
    model: str,
    ids: list[int],
    values: dict[str, Any],
    *,
    check_write_date: str | None = None,
) -> bool:
    """Update records, optionally checking freshness immediately beforehand.

    ``check_write_date`` is a best-effort, non-atomic preflight: another
    writer can change records between the read and write, and same-second
    changes can be invisible. All IDs must match the supplied timestamp.
    An unusable or stale observation prevents the mutation attempt.
    """
    prepared = process_values(values)
    if check_write_date is not None:
        validate_write_date(check_write_date)
        records = self._transport.read(model, ids, ["write_date"])
        check_observations(model, ids, check_write_date, records)
    return self._transport.write(model, ids, prepared)
unlink(model: str, ids: list[int]) -> bool

Delete records.

Source code in src/vodoo/client.py
def unlink(
    self,
    model: str,
    ids: list[int],
) -> bool:
    """Delete records."""
    return self._transport.unlink(model, ids)

fields_get

fields_get(model: str, fields: list[str] | None = None, attributes: list[str] | None = None) -> dict[str, Any]

Return field definitions for a model.

PARAMETER DESCRIPTION
model

Odoo model name (e.g., 'res.partner')

TYPE: str

fields

Optional list of field names to inspect. None returns all fields.

TYPE: list[str] | None DEFAULT: None

attributes

Optional list of field attributes to return (e.g., ['string', 'type', 'required']). None returns all attributes.

TYPE: list[str] | None DEFAULT: None

RETURNS DESCRIPTION
dict[str, Any]

Dictionary mapping field names to their attribute dicts.

Source code in src/vodoo/client.py
def fields_get(
    self,
    model: str,
    fields: list[str] | None = None,
    attributes: list[str] | None = None,
) -> dict[str, Any]:
    """Return field definitions for a model.

    Args:
        model: Odoo model name (e.g., ``'res.partner'``)
        fields: Optional list of field names to inspect.
                ``None`` returns all fields.
        attributes: Optional list of field attributes to return
                    (e.g., ``['string', 'type', 'required']``).
                    ``None`` returns all attributes.

    Returns:
        Dictionary mapping field names to their attribute dicts.
    """
    args: list[Any] = [fields or []]
    kwargs: dict[str, Any] = {}
    if attributes is not None:
        kwargs["attributes"] = attributes
    result: dict[str, Any] = self._transport.execute_kw(
        model, "fields_get", args, kwargs or None
    )
    return result
name_search(model: str, name: str, domain: list[Any] | None = None, limit: int = 7) -> list[tuple[int, str]]

Autocomplete search returning (id, display_name) pairs.

Source code in src/vodoo/client.py
def name_search(
    self,
    model: str,
    name: str,
    domain: list[Any] | None = None,
    limit: int = 7,
) -> list[tuple[int, str]]:
    """Autocomplete search returning (id, display_name) pairs."""
    return self._transport.name_search(model, name, domain, limit)