Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ Changelog
---------


2.5.x (unreleased)
~~~~~~~~~~~~~~~~~~

* Json2: performance improvement. Parameters of standard public
methods are predefined.

* Json2: improve error handling, catch HTTP status 401, 403 and 404.

* Json2: set ``Accept: application/json`` HTTP header.


2.5.1 (2025-11-11)
~~~~~~~~~~~~~~~~~~

Expand Down
75 changes: 49 additions & 26 deletions odooly.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@
"of the following exception:\n\n")
_pending_state = ('state', 'not in',
['uninstallable', 'uninstalled', 'installed'])
_base_method_params = {
'copy': ['ids', 'defaults'],
'create': ['vals_list'],
'read': ['ids', 'fields', 'load'],
'search': ['domain', 'offset', 'limit', 'order'],
'search_count': ['domain', 'limit'],
'search_read': ['domain', 'fields', 'offset', 'limit', 'order'],
'write': ['ids', 'vals'],
}
http_context = None

if os.getenv('ODOOLY_SSL_UNVERIFIED'):
Expand All @@ -133,12 +142,14 @@ def ServerProxy(url, transport, allow_none, _ServerProxy=ServerProxy):
if not requests:
from urllib.request import HTTPCookieProcessor, HTTPSHandler, Request, build_opener

Ids, Id1 = type('ids', (list,), {'__slots__': ()}), type('id1', (int,), {'__slots__': ()})


class HTTPSession:
if requests: # requests.Session
def __init__(self):
self._session = requests.Session()
self._session.headers.update({'User-Agent': USER_AGENT})
self._session.headers.update({'User-Agent': USER_AGENT, 'Accept': 'application/json'})

def request(self, url, *, method='POST', data=None, json=None, headers=None, **kw):
resp = self._session.request(method, url, data=data, json=json, headers=headers, **kw)
Expand All @@ -154,7 +165,7 @@ def _parse_error(self, error):
else: # urllib.request
def __init__(self):
self._session = build_opener(HTTPCookieProcessor(), HTTPSHandler(context=http_context))
self._session.addheaders = [('User-Agent', USER_AGENT)]
self._session.addheaders = [('User-Agent', USER_AGENT), ('Accept', 'application/json')]

def request(self, url, *, method='POST', data=None, json=None, headers=None, _json=json, **kw):
headers = dict(headers or ())
Expand Down Expand Up @@ -513,6 +524,7 @@ def __dir__(self):
def __getattr__(self, name):
if name not in self._methods:
raise AttributeError(f"'Service' object has no attribute {name!r}")

def sanitize(args):
if self._endpoint != 'db' and len(args) > 2:
args = list(args)
Expand Down Expand Up @@ -547,27 +559,33 @@ def __init__(self, client, database, api_key):
'Content-Type': 'application/json',
'X-Odoo-Database': database or '',
}
self._method_params = {}
self._method_params = {'base': _base_method_params}
self._printer = client._printer

def doc(self, model):
"""Documentation of the `model`."""
return self._request(f'{self._doc_endpoint}/{model}.json')

def _prepare_params(self, model, method, args, kwargs):
if not args:
return {**kwargs}
def _list_params_names(self, model, method):
try:
arg_names = self._method_params[model][method]
return (self._method_params['base'].get(method) or
self._method_params[model][method])
except KeyError:
methods = self.doc(model).get('methods') or {}
self._method_params[model] = dict_methods = {}
for key, vals in methods.items():
arg_names = list(vals['parameters'])
if 'model' not in vals.get('api', ()):
arg_names.insert(0, 'ids')
dict_methods[key] = arg_names
arg_names = dict_methods.setdefault(method, ())
self._method_params[model] = dict_methods = {}
for key, vals in methods.items():
arg_names = list(vals['parameters'])
if 'model' not in vals.get('api', ()):
arg_names.insert(0, 'ids')
dict_methods[key] = arg_names
return dict_methods.setdefault(method, ())

def _prepare_params(self, model, method, args, kwargs):
if not args:
return {**kwargs}
if len(args) == 1 and isinstance(args[0], (Ids, Id1)):
return {'ids': args[0], **kwargs}
arg_names = self._list_params_names(model, method)
params = dict(zip(arg_names, args))
params.update(kwargs)
if len(args) > len(arg_names) and self._printer:
Expand Down Expand Up @@ -596,7 +614,12 @@ def _http_req(self, path, params, method):
return self._http.request(url, method=method, json=params, headers=self._headers)
except OSError as exc:
status_code, result = self._http._parse_error(exc)
if status_code == 422: # UnprocessableEntity
if status_code in (401, 403, 404, 422):
# Unauthorized, Forbidden, NotFound, UnprocessableEntity
if isinstance(result, str):
lines = re.findall('>(.+)<', result)
result = {'name': exc.__class__.__name__, 'debug': None,
'arguments': (f'{lines[0]} - {lines[-1]}',)}
raise ServerError({'code': status_code, 'data': result})
raise

Expand Down Expand Up @@ -2020,7 +2043,7 @@ def get_metadata(self):
if self.env.client.version_info < 8.0:
rv = self._execute('perm_read', self.ids)
return rv[0] if (rv and self.id != self.ids) else (rv or None)
return self._execute('get_metadata', self.ids)
return self._execute('get_metadata', Ids(self.ids))

def with_env(self, env):
return env[self._name].browse(self.id)
Expand Down Expand Up @@ -2127,14 +2150,14 @@ def write(self, values):
return True
values = self._model._unbrowse_values(values)
self._invalidate_cache()
return self._execute('write', self.ids, values)
return self._execute('write', Ids(self.ids), values)

def unlink(self):
"""Delete the record(s) from the database."""
if not self.id:
return True
self._invalidate_cache()
return self._execute('unlink', self.ids)
return self._execute('unlink', Ids(self.ids))


class RecordList(BaseRecord):
Expand Down Expand Up @@ -2216,7 +2239,7 @@ def read(self, fields=None):
idnames = [(val['id'], val['display_name']) for val in values]
self.__dict__.update({'id': ids, 'ids': ids, '_idnames': idnames})
else:
values = self._model.read(self.id, fields, order=True) if self.id else []
values = self._model.read(Ids(self.ids), fields, order=True) if self.ids else []

return fmt(values)

Expand All @@ -2229,7 +2252,7 @@ def copy(self, default=None):
Supported since Odoo 18.
"""
default = default and self._model._unbrowse_values(default)
new_ids = self._execute('copy', self.ids, default)
new_ids = self._execute('copy', Ids(self.ids), default)
return RecordList(self._model, new_ids)

@property
Expand All @@ -2241,7 +2264,7 @@ def _external_id(self):
only one of them is returned (randomly).
"""
xml_ids = {r.id: xml_id for (xml_id, r) in
self._model._get_external_ids(self.id).items()}
self._model._get_external_ids(Ids(self.ids)).items()}
return [xml_ids.get(res_id, False) for res_id in self.id]

def __getattr__(self, attr):
Expand All @@ -2258,7 +2281,7 @@ def __getattr__(self, attr):

def wrapper(self, *params, **kwargs):
"""Wrapper for client.execute({!r}, {!r}, [...], *params, **kwargs)."""
return self._execute(attr, self.id, *params, **kwargs)
return self._execute(attr, Ids(self.ids), *params, **kwargs)
return _memoize(self, attr, wrapper, (self._name, attr))

def __setattr__(self, attr, value):
Expand Down Expand Up @@ -2329,7 +2352,7 @@ def read(self, fields=None):
The argument `fields` accepts different kinds of values.
See :meth:`Model.read` for details.
"""
rv = self._model.read(self.id, fields)
rv = self._model.read(Id1(self.id), fields)
if rv is not None and isinstance(fields, str) and fields in self._model._keys:
return self._update({fields: rv})[fields]
if isinstance(rv, dict):
Expand All @@ -2343,7 +2366,7 @@ def copy(self, default=None):
values of the new record.
"""
default = default and self._model._unbrowse_values(default)
new_id = self._execute('copy', self.id, default)
new_id = self._execute('copy', Id1(self.id), default)
if isinstance(new_id, list):
[new_id] = new_id or [False]
return Record(self._model, new_id)
Expand All @@ -2362,7 +2385,7 @@ def _external_id(self):
with default value False if there's none. If multiple IDs
exist, only one of them is returned (randomly).
"""
xml_ids = self._model._get_external_ids([self.id])
xml_ids = self._model._get_external_ids(Ids(self.ids))
return list(xml_ids)[0] if xml_ids else False

def _set_external_id(self, xml_id):
Expand All @@ -2385,7 +2408,7 @@ def __getattr__(self, attr):

def wrapper(self, *params, **kwargs):
"""Wrapper for client.execute({!r}, {!r}, {:d}, *params, **kwargs)."""
res = self._execute(attr, [self.id], *params, **kwargs)
res = self._execute(attr, Ids(self.ids), *params, **kwargs)
self._invalidate_cache()
if isinstance(res, list) and len(res) == 1:
return res[0]
Expand Down