Skip to content

Commit 85140b4

Browse files
author
Callum Dickinson
committed
Replace OdooRPC with direct requests
Remove the OpenStack Odoo Client for Python's usage of the OdooRPC library, and replace it with direct JSON RPC requests made internally using the `httpx` library. Functionally, the most important change here is that we now do our own JSON encoding and decoding. This allows us to decode non-integer numbers as `Decimal` objects instead of floats, ensuring that number handling is as accurate as possible (on the client side anyway, Odoo still stores them as floats unfortunately). There are other benefits, such as performance improvements from avoiding OdooRPC's ORM abstractions and other additional processing we don't need.
1 parent 2f9cd30 commit 85140b4

25 files changed

Lines changed: 526 additions & 304 deletions

openstack_odooclient/base/client.py

Lines changed: 168 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,23 @@
1515

1616
from __future__ import annotations
1717

18-
import ssl
19-
import urllib.request
18+
import json
19+
import random
2020

2121
from pathlib import Path
22-
from typing import TYPE_CHECKING, Literal, Type, overload
22+
from typing import TYPE_CHECKING, Any, Type
23+
24+
import httpx
2325

24-
from odoorpc import ODOO # type: ignore[import]
2526
from packaging.version import Version
2627
from typing_extensions import get_type_hints # 3.11 and later
2728

28-
from ..util import is_subclass
29+
from ..util import JSONDecoder, JSONEncoder, is_subclass
2930
from .record import RecordBase
3031
from .record_manager import RecordManagerBase
3132

3233
if TYPE_CHECKING:
33-
from odoorpc.db import DB # type: ignore[import]
34-
from odoorpc.env import Environment # type: ignore[import]
35-
from odoorpc.report import Report # type: ignore[import]
34+
from collections.abc import Mapping
3635

3736

3837
class ClientBase:
@@ -81,92 +80,22 @@ class ClientBase:
8180
:type version: str | None, optional
8281
"""
8382

84-
@overload
8583
def __init__(
8684
self,
8785
*,
88-
hostname: str | None = ...,
89-
database: str | None = ...,
90-
username: str | None = ...,
91-
password: str | None = ...,
92-
protocol: str = "jsonrpc",
93-
port: int = 8069,
94-
verify: bool | str | Path = ...,
95-
version: str | None = ...,
96-
odoo: ODOO,
97-
) -> None: ...
98-
99-
@overload
100-
def __init__(
101-
self,
102-
*,
103-
hostname: str,
86+
base_url: str,
10487
database: str,
10588
username: str,
10689
password: str,
107-
protocol: str = "jsonrpc",
108-
port: int = 8069,
109-
verify: bool | str | Path = ...,
110-
version: str | None = ...,
111-
odoo: Literal[None] = ...,
112-
) -> None: ...
113-
114-
@overload
115-
def __init__(
116-
self,
117-
*,
118-
hostname: str | None = ...,
119-
database: str | None = ...,
120-
username: str | None = ...,
121-
password: str | None = ...,
122-
protocol: str = "jsonrpc",
123-
port: int = 8069,
124-
verify: bool | str | Path = ...,
125-
version: str | None = ...,
126-
odoo: ODOO | None = ...,
127-
) -> None: ...
128-
129-
def __init__(
130-
self,
131-
*,
132-
hostname: str | None = None,
133-
database: str | None = None,
134-
username: str | None = None,
135-
password: str | None = None,
136-
protocol: str = "jsonrpc",
137-
port: int = 8069,
13890
verify: bool | str | Path = True,
139-
version: str | None = None,
140-
odoo: ODOO | None = None,
91+
timeout: int | None = None,
14192
) -> None:
142-
# If an OdooRPC object is provided, use that directly.
143-
# Otherwise, make a new one with the provided settings.
144-
if odoo:
145-
self._odoo = odoo
146-
else:
147-
opener = None
148-
if protocol.endswith("+ssl"):
149-
ssl_verify = verify is not False
150-
ssl_cafile = (
151-
str(verify) if isinstance(verify, (Path, str)) else None
152-
)
153-
if not ssl_verify or ssl_cafile:
154-
ssl_context = ssl.create_default_context(cafile=ssl_cafile)
155-
if not ssl_verify:
156-
ssl_context.check_hostname = False
157-
ssl_context.verify_mode = ssl.CERT_NONE
158-
opener = urllib.request.build_opener(
159-
urllib.request.HTTPSHandler(context=ssl_context),
160-
urllib.request.HTTPCookieProcessor(),
161-
)
162-
self._odoo = ODOO(
163-
protocol=protocol,
164-
host=hostname,
165-
port=port,
166-
version=version,
167-
opener=opener,
168-
)
169-
self._odoo.login(database, username, password)
93+
self._base_url = base_url
94+
self._database = database
95+
self._username = username
96+
self._password = password
97+
self._verify = str(verify) if isinstance(verify, Path) else verify
98+
self._timeout = timeout
17099
self._env_manager_mapping: dict[str, RecordManagerBase] = {}
171100
"""An internal mapping between env (model) names and their managers.
172101
@@ -189,47 +118,170 @@ def __init__(
189118
for attr_name, attr_type in get_type_hints(type(self)).items():
190119
if is_subclass(attr_type, RecordManagerBase):
191120
setattr(self, attr_name, attr_type(self))
192-
193-
@property
194-
def odoo(self) -> ODOO:
195-
"""The OdooRPC connection object currently being used
196-
by this client.
197-
"""
198-
return self._odoo
199-
200-
@property
201-
def db(self) -> DB:
202-
"""The database management service."""
203-
return self._odoo.db
204-
205-
@property
206-
def report(self) -> Report:
207-
"""The report management service."""
208-
return self._odoo.report
209-
210-
@property
211-
def env(self) -> Environment:
212-
"""The OdooRPC environment wrapper object.
213-
214-
This allows interacting with models that do not have managers
215-
within this Odoo client.
216-
Usage is the same as on a native ``odoorpc.ODOO`` object.
217-
"""
218-
return self._odoo.env
121+
self.login()
219122

220123
@property
221124
def user_id(self) -> int:
222125
"""The ID for the currently logged in user."""
223-
return self._odoo.env.uid
126+
return self._user_id
224127

225128
@property
226129
def version(self) -> Version:
227130
"""The version of the server,
228131
as a comparable ``packaging.version.Version`` object.
229132
"""
230-
return Version(self._odoo.version)
133+
return self._odoo_version
231134

232135
@property
233136
def version_str(self) -> str:
234137
"""The version of the server, as a string."""
235-
return self._odoo.version
138+
return self._odoo_version_str
139+
140+
def _jsonrpc(
141+
self,
142+
*,
143+
params: Mapping[str, Any] | None = None,
144+
url: str = "/jsonrpc",
145+
) -> httpx.Response:
146+
return self._http_client.post(
147+
url,
148+
headers={"Content-Type": "application/json"},
149+
content=json.dumps(
150+
{
151+
"jsonrpc": "2.0",
152+
"method": "call",
153+
"params": dict(params) if params else {},
154+
"id": random.randint(0, 1000000000), # noqa: S311
155+
},
156+
cls=JSONEncoder,
157+
separators=(",", ":"),
158+
),
159+
)
160+
161+
def login(self) -> None:
162+
"""Login to the Odoo database with the configured
163+
username and password.
164+
165+
Fetches and stores a new session cookie, usable until
166+
it expires after a pre-determined amount of time.
167+
168+
If a request is made to Odoo after the session cookie
169+
has expired, the Odoo client will automatically run this
170+
method to refresh the session cookie.
171+
"""
172+
# Set up the HTTP client session that will be used
173+
# for all requests.
174+
self._http_client = httpx.Client(
175+
base_url=self._base_url,
176+
verify=self._verify,
177+
timeout=self._timeout,
178+
)
179+
# Login, and set up the user context.
180+
response = self._jsonrpc(
181+
params={
182+
"service": "common",
183+
"method": "login",
184+
"args": [self._database, self._username, self._password],
185+
},
186+
)
187+
# TODO(callumdickinson): Handle HTTP 401.
188+
user_id: int | None = response.json()["result"]
189+
if not user_id:
190+
# TODO(callumdickinson): Custom exception class.
191+
raise ValueError("Incorrect username or password")
192+
response = self._jsonrpc(
193+
params={
194+
"service": "object",
195+
"method": "execute",
196+
"args": [
197+
self._database,
198+
self._username,
199+
self._password,
200+
"res.users",
201+
"context_get",
202+
],
203+
},
204+
)
205+
context: dict[str, Any] = response.json()["result"]
206+
context["uid"] = user_id
207+
self._user_id = user_id
208+
self._context = context
209+
# Discover the Odoo server's version.
210+
response = self._jsonrpc(url="/web/webclient/version_info")
211+
self._odoo_version_str: str = response.json()["server_version"]
212+
self._odoo_version = Version(self._odoo_version_str)
213+
214+
def close(self) -> None:
215+
self._http_client.close()
216+
217+
def execute(
218+
self,
219+
model: str,
220+
method: str,
221+
/,
222+
*args: Any,
223+
) -> Any:
224+
"""Invoke a method on the given model,
225+
passing all other positional arguments
226+
as parameters, and return the result.
227+
228+
:param model: The model to run the method on
229+
:type model: str
230+
:param method: The method to invoke
231+
:type method: str
232+
:return: The return value of the method
233+
:rtype: Any
234+
"""
235+
response = self._jsonrpc(
236+
params={
237+
"service": "object",
238+
"method": "execute",
239+
"args": [
240+
self._database,
241+
self._user_id,
242+
self._password,
243+
model,
244+
method,
245+
*args,
246+
],
247+
},
248+
)
249+
data: dict[str, Any] = json.loads(response.text, cls=JSONDecoder)
250+
return data.get("result")
251+
252+
def execute_kw(
253+
self,
254+
model: str,
255+
method: str,
256+
/,
257+
*args: Any,
258+
**kwargs: Any,
259+
) -> Any:
260+
"""Invoke a method on the given model,
261+
passing all other positional arguments
262+
and all keyword arguments as parameters,
263+
and return the result.
264+
265+
:param model: The model to run the method on
266+
:type model: str
267+
:param method: The method to invoke
268+
:type method: str
269+
:return: The return value of the method
270+
:rtype: Any
271+
"""
272+
response = self._jsonrpc(
273+
params={
274+
"service": "object",
275+
"method": "execute_kw",
276+
"args": [
277+
self._database,
278+
self._user_id,
279+
self._password,
280+
model,
281+
method,
282+
[args, kwargs],
283+
],
284+
},
285+
)
286+
data: dict[str, Any] = json.loads(response.text, cls=JSONDecoder)
287+
return data.get("result")

openstack_odooclient/base/record.py

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,6 @@
4242
if TYPE_CHECKING:
4343
from collections.abc import Mapping, Sequence
4444

45-
from odoorpc import ODOO # type: ignore[import]
46-
from odoorpc.env import Environment # type: ignore[import]
47-
4845
from .client import ClientBase
4946

5047
RecordManager = TypeVar("RecordManager", bound="RecordManagerBase")
@@ -206,16 +203,6 @@ def _manager(self) -> RecordManager:
206203
mapping = self._client._record_manager_mapping
207204
return mapping[type(self)] # type: ignore[return-value]
208205

209-
@property
210-
def _odoo(self) -> ODOO:
211-
"""The OdooRPC connection object this record was created from."""
212-
return self._client._odoo
213-
214-
@property
215-
def _env(self) -> Environment:
216-
"""The OdooRPC environment object this record was created from."""
217-
return self._manager._env
218-
219206
@property
220207
def _type_hints(self) -> MappingProxyType[str, Any]:
221208
return self._manager._record_type_hints
@@ -294,7 +281,7 @@ def refresh(self) -> Self:
294281
"""
295282
return type(self)(
296283
client=self._client,
297-
record=self._env.read(
284+
record=self._manager._read(
298285
self.id,
299286
fields=self._fields,
300287
)[0],

0 commit comments

Comments
 (0)