diff --git a/.github/workflows/python-build.yml b/.github/workflows/python-build.yml index 9fe787f..fe9285c 100644 --- a/.github/workflows/python-build.yml +++ b/.github/workflows/python-build.yml @@ -14,7 +14,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - python-version: [ "3.11" ] + python-version: [ "3.9", "3.10", "3.11", "3.12" ] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 10c2511..1ed55bd 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - python-version: [ "3.11" ] + python-version: [ "3.9", "3.10", "3.11", "3.12" ] steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 293a2a9..b030428 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ odoolib/__pycache__ MANIFEST build dist +odoo_client_lib.egg-info diff --git a/README.rst b/README.rst index 4d539b0..c7fa375 100644 --- a/README.rst +++ b/README.rst @@ -42,12 +42,11 @@ is equivalent to the following interaction when you are coding an Odoo addon and executed on the server: :: user_osv = self.pool.get('res.users') - ids = user_osv.search(cr, uid, [("login", "=", "admin")]) - user_info = user_osv.read(cr, uid, ids[0], ["name"]) + ids = user_osv.search([("login", "=", "admin")]) + user_info = user_osv.read(ids[0], ["name"]) Also note that coding using Model objects offer some syntaxic sugar compared to vanilla addon coding: -- You don't have to forward the "cr" and "uid" to all methods. - The read() method automatically sort rows according the order of the ids you gave it to. - The Model objects also provides the search_read() method that combines a search and a read, example: :: @@ -62,9 +61,50 @@ Here are also some considerations about coding using the Odoo Client Library: - The browse() method can not be used. That method returns a dynamic proxy that lazy loads the rows' data from the database. That behavior is not implemented in the Odoo Client Library. +JSON-RPC +-------- + +The jsonrpc protocol is available since Odoo version 8.0. It has the exact same methods as the XML-RPC protocol, +but uses a different endpoint: `/jsonrpc/`. The Odoo Client Library provides a `get_connection()` to specify the protocol to use. + +The only difference between XML-RPC and JSON-RPC is that the latter uses a JSON payload instead of an XML payload. + +JSON2 +----- + +The Json2 appears with Odoo version 19.0, and requires every method to be called with named parameters. +If the method is called with positional arguments, the library will try to introspect the method +and convert the positional arguments into named parameters. This introspection is done only once per model. +Introspection means a server call, which can slow down the first method call for a given model. Using named parameters +is recommended for performance reasons. + +With this new `/json/2/` endpoint, this library is less useful than before, but makes it easy to move older scripts +to the new endpoint. + Compatibility ------------- - XML-RPC: OpenERP version 6.1 and superior -- JSON-RPC: Odoo version 8.0 (upcoming) and superior +- JSON-RPC: Odoo version 8.0 and superior + +- JSON2: Odoo version 19.0 and superior + +SSL Communication +----------------- + +The Odoo Client Library supports both XML-RPC and JSON-RPC over SSL. The difference is that it uses +the HTTPS protocol, which means that the communication is encrypted. This is useful when you want to +communicate with an Odoo server over the internet, as it prevents eavesdropping and man-in-the-middle attacks. +To use XML-RPC over SSL, you can specify the protocol when creating the connection: :: + + connection = odoolib.get_connection(hostname="localhost", protocol="xmlrpcs", ...) + +the possible values for the protocol parameter are: +- `xmlrpc`: standard XML-RPC over HTTP (Odoo version 6.1 to 19.0) +- `xmlrpcs`: XML-RPC over HTTPS (Odoo version 6.1 to 19.0) +- `jsonrpc`: standard JSON-RPC over HTTP (Odoo version 8.0 to 19.0) +- `jsonrpcs`: JSON-RPC over HTTPS (Odoo version 8.0 to 19.0) +- `json2`: JSON2 over HTTP (Odoo version 19.0 and superior) +- `json2s`: JSON2 over HTTPS (Odoo version 19.0 and superior) + diff --git a/licence.txt b/licence.txt new file mode 100644 index 0000000..43d513b --- /dev/null +++ b/licence.txt @@ -0,0 +1,15 @@ +Copyright (C) Stephane Wirtel +Copyright (C) 2011 Nicolas Vanhoren +Copyright (C) 2011 OpenERP s.a. () +Copyright (C) 2018 Nicolas Seinlet +Copyright (C) 2018 Odoo s.a. () + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/odoolib/__init__.py b/odoolib/__init__.py index f3d58ef..15b6a64 100644 --- a/odoolib/__init__.py +++ b/odoolib/__init__.py @@ -1,32 +1 @@ -# -*- coding: utf-8 -*- -############################################################################## -# -# Copyright (C) Stephane Wirtel -# Copyright (C) 2011 Nicolas Vanhoren -# Copyright (C) 2011 OpenERP s.a. () -# Copyright (C) 2018 Odoo s.a. (). -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -############################################################################## - from .main import * diff --git a/odoolib/dates.py b/odoolib/dates.py index 918ce36..1bd481d 100644 --- a/odoolib/dates.py +++ b/odoolib/dates.py @@ -1,34 +1,3 @@ -# -*- coding: utf-8 -*- -############################################################################## -# -# Copyright (C) Stephane Wirtel -# Copyright (C) 2011 Nicolas Vanhoren -# Copyright (C) 2011 OpenERP s.a. () -# Copyright (C) 2018 Odoo s.a. (). -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -############################################################################## - import datetime DEFAULT_SERVER_DATE_FORMAT = "%Y-%m-%d" diff --git a/odoolib/json2.py b/odoolib/json2.py new file mode 100644 index 0000000..76e6086 --- /dev/null +++ b/odoolib/json2.py @@ -0,0 +1,128 @@ +import logging +import httpx + +from http import HTTPStatus + +from .tools import AuthenticationError, RemoteModel, _getChildLogger + +_logger = logging.getLogger(__name__) +DEFAULT_TIMEOUT = 60 + + +class JsonModel(RemoteModel): + def __init__(self, connection, model_name): + res = super().__init__(connection, model_name) + self.__logger = _getChildLogger(_getChildLogger(_logger, 'object'), model_name or "") + self.methods = {} + self.model_methods = [] + return res + + def __getattr__(self, method): + """ + Provides proxy methods that will forward calls to the model on the remote Odoo server. + + :param method: The method for the linked model (search, read, write, unlink, create, ...) + """ + def proxy(*args, **kwargs): + """ + :param args: A list of values for the method + """ + # self.__logger.debug(args) + data = kwargs + if args: + # Should convert args list into dict of args + self._introspect() + offset = 0 + if method not in self.model_methods and 'ids' not in kwargs.keys(): + data['ids'] = args[0] + offset = 1 + for i in range(offset, len(args)): + if i-offset < len(self.methods[method]): + data[self.methods[method][i-offset]] = args[i] + else: + _logger.warning(f"Method {method} called with too many arguments: {args}") + + result = httpx.post( + self._url(method), + headers=self.connection.bearer_header, + json=data, + timeout=DEFAULT_TIMEOUT, + ) + + if result.status_code == HTTPStatus.UNAUTHORIZED: + raise AuthenticationError("Authentication failed. Please check your API key.") + if result.status_code == 422: + raise ValueError(f"Invalid request: {result.text} for data {data}") + if result.status_code != 200: + raise ValueError(f"Unexpected status code {result.status_code}: {result.text}") + return result.json() + return proxy + + def _introspect(self): + if not self.methods: + url = f"{self.connection.connector.url.removesuffix('/json/2/')}/doc-bearer/{self.model_name}.json" + response = httpx.get(url, headers=self.connection.bearer_header) + response.raise_for_status() + m = response.json().get('methods', {}) + self.methods = {k: tuple(m[k]['parameters'].keys()) for k in m.keys()} + self.model_methods = [ k for k in m.keys() if 'model' in m[k].get('api', []) ] + + def read(self, *args, **kwargs): + res = self.__getattr__('read')(*args, **kwargs) + if len(res) == 1: + return res[0] + return res + + def _url(self, method): + """ + Returns the URL of the Odoo server. + """ + return f"{self.connection.connector.url}{self.model_name}/{method}" + + +class Json2Connector(object): + def __init__(self, hostname, port="8069"): + """ + Initialize by specifying the hostname and the port. + :param hostname: The hostname of the computer holding the instance of Odoo. + :param port: The port used by the Odoo instance for JsonRPC (default to 8069). + """ + if port != 80: + self.url = f'http://{hostname}:{port}/json/2/' + else: + self.url = f'http://{hostname}/json/2/' + + +class Json2SConnector(Json2Connector): + def __init__(self, hostname, port="443"): + super().__init__(hostname, port) + if port != 443: + self.url = f'https://{hostname}:{port}/json/2/' + else: + self.url = f'https://{hostname}/json/2/' + + +class Json2Connection(object): + """ + A class representing a connection to an Odoo server. + """ + + def __init__(self, connector, database, api_key): + self.connector = connector + self.database = database + self.bearer_header = {"Authorization": f"Bearer {api_key}", 'Content-Type': 'application/json; charset=utf-8', "X-Odoo-Database": database} + self.user_context = None + + def get_model(self, model_name): + return JsonModel(self, model_name) + + def get_connector(self): + return self.connector + + def get_user_context(self): + """ + Query the default context of the user. + """ + if not self.user_context: + self.user_context = self.get_model('res.users').context_get() + return self.user_context diff --git a/odoolib/main.py b/odoolib/main.py index fde80be..1200c67 100644 --- a/odoolib/main.py +++ b/odoolib/main.py @@ -36,316 +36,13 @@ Code repository: https://github.com/odoo/odoo-client-lib """ -import sys -import requests -if sys.version_info >= (3, 0, 0): - from xmlrpc.client import ServerProxy -else: - from xmlrpclib import ServerProxy import logging -import json -import random -_logger = logging.getLogger(__name__) - -def _getChildLogger(logger, subname): - return logging.getLogger(logger.name + "." + subname) - -class Connector(object): - """ - The base abstract class representing a connection to an Odoo Server. - """ - - __logger = _getChildLogger(_logger, 'connector') - - def get_service(self, service_name): - """ - Returns a Service instance to allow easy manipulation of one of the services offered by the remote server. - - :param service_name: The name of the service. - """ - return Service(self, service_name) - -class XmlRPCConnector(Connector): - """ - A type of connector that uses the XMLRPC protocol. - """ - PROTOCOL = 'xmlrpc' - - __logger = _getChildLogger(_logger, 'connector.xmlrpc') - - def __init__(self, hostname, port=8069): - """ - Initialize by specifying the hostname and the port. - :param hostname: The hostname of the computer holding the instance of Odoo. - :param port: The port used by the Odoo instance for XMLRPC (default to 8069). - """ - self.url = 'http://%s:%d/xmlrpc' % (hostname, port) - - def send(self, service_name, method, *args): - url = '%s/%s' % (self.url, service_name) - service = ServerProxy(url) - return getattr(service, method)(*args) - -class XmlRPCSConnector(XmlRPCConnector): - """ - A type of connector that uses the secured XMLRPC protocol. - """ - PROTOCOL = 'xmlrpcs' - - __logger = _getChildLogger(_logger, 'connector.xmlrpcs') - - def __init__(self, hostname, port=8069): - super(XmlRPCSConnector, self).__init__(hostname, port) - self.url = 'https://%s:%d/xmlrpc' % (hostname, port) - -class JsonRPCException(Exception): - def __init__(self, error): - self.error = error - def __str__(self): - return repr(self.error) - -def json_rpc(url, fct_name, params): - data = { - "jsonrpc": "2.0", - "method": fct_name, - "params": params, - "id": random.randint(0, 1000000000), - } - result_req = requests.post(url, data=json.dumps(data), headers={ - "Content-Type":"application/json", - }) - result = result_req.json() - if result.get("error", None): - raise JsonRPCException(result["error"]) - return result.get("result", False) - -class JsonRPCConnector(Connector): - """ - A type of connector that uses the JsonRPC protocol. - """ - PROTOCOL = 'jsonrpc' - - __logger = _getChildLogger(_logger, 'connector.jsonrpc') - - def __init__(self, hostname, port=8069): - """ - Initialize by specifying the hostname and the port. - :param hostname: The hostname of the computer holding the instance of Odoo. - :param port: The port used by the Odoo instance for JsonRPC (default to 8069). - """ - self.url = 'http://%s:%d/jsonrpc' % (hostname, port) - - def send(self, service_name, method, *args): - return json_rpc(self.url, "call", {"service": service_name, "method": method, "args": args}) - -class JsonRPCSConnector(Connector): - """ - A type of connector that uses the JsonRPC protocol. - """ - PROTOCOL = 'jsonrpcs' - - __logger = _getChildLogger(_logger, 'connector.jsonrpc') - - def __init__(self, hostname, port=8069): - """ - Initialize by specifying the hostname and the port. - :param hostname: The hostname of the computer holding the instance of Odoo. - :param port: The port used by the Odoo instance for JsonRPC (default to 8069). - """ - self.url = 'https://%s:%d/jsonrpc' % (hostname, port) +from .rpc import XmlRPCConnector, XmlRPCSConnector, JsonRPCConnector, JsonRPCSConnector, Connection +from .json2 import Json2Connection, Json2Connector, Json2SConnector - def send(self, service_name, method, *args): - return json_rpc(self.url, "call", {"service": service_name, "method": method, "args": args}) - -class Service(object): - """ - A class to execute RPC calls on a specific service of the remote server. - """ - def __init__(self, connector, service_name): - """ - :param connector: A valid Connector instance. - :param service_name: The name of the service on the remote server. - """ - self.connector = connector - self.service_name = service_name - self.__logger = _getChildLogger(_getChildLogger(_logger, 'service'),service_name or "") - - def __getattr__(self, method): - """ - :param method: The name of the method to execute on the service. - """ - self.__logger.debug('method: %r', method) - def proxy(*args): - """ - :param args: A list of values for the method - """ - self.__logger.debug('args: %r', args) - result = self.connector.send(self.service_name, method, *args) - self.__logger.debug('result: %r', result) - return result - return proxy - -class Connection(object): - """ - A class to represent a connection with authentication to an Odoo Server. - It also provides utility methods to interact with the server more easily. - """ - __logger = _getChildLogger(_logger, 'connection') - - def __init__(self, connector, - database=None, - login=None, - password=None, - user_id=None): - """ - Initialize with login information. The login information is facultative to allow specifying - it after the initialization of this object. - - :param connector: A valid Connector instance to send messages to the remote server. - :param database: The name of the database to work on. - :param login: The login of the user. - :param password: The password of the user. - :param user_id: The user id is a number identifying the user. This is only useful if you - already know it, in most cases you don't need to specify it. - """ - self.connector = connector - - self.set_login_info(database, login, password, user_id) - self.user_context = None - - def set_login_info(self, database, login, password, user_id=None): - """ - Set login information after the initialisation of this object. - - :param connector: A valid Connector instance to send messages to the remote server. - :param database: The name of the database to work on. - :param login: The login of the user. - :param password: The password of the user. - :param user_id: The user id is a number identifying the user. This is only useful if you - already know it, in most cases you don't need to specify it. - """ - self.database, self.login, self.password = database, login, password - - self.user_id = user_id - - def check_login(self, force=True): - """ - Checks that the login information is valid. Throws an AuthenticationError if the - authentication fails. - - :param force: Force to re-check even if this Connection was already validated previously. - Default to True. - """ - if self.user_id and not force: - return - - if not self.database or not self.login or self.password is None: - raise AuthenticationError("Credentials not provided") - - # TODO use authenticate instead of login - self.user_id = self.get_service("common").login(self.database, self.login, self.password) - if not self.user_id: - raise AuthenticationError("Authentication failure") - self.__logger.debug("Authenticated with user id %s", self.user_id) - - def get_user_context(self): - """ - Query the default context of the user. - """ - if not self.user_context: - self.user_context = self.get_model('res.users').context_get() - return self.user_context - - def get_model(self, model_name): - """ - Returns a Model instance to allow easy remote manipulation of an Odoo model. - - :param model_name: The name of the model. - """ - return Model(self, model_name) - - def get_service(self, service_name): - """ - Returns a Service instance to allow easy manipulation of one of the services offered by the remote server. - Please note this Connection instance does not need to have valid authentication information since authentication - is only necessary for the "object" service that handles models. - - :param service_name: The name of the service. - """ - return self.connector.get_service(service_name) - -class AuthenticationError(Exception): - """ - An error thrown when an authentication to an Odoo server failed. - """ - pass - -class Model(object): - """ - Useful class to dialog with one of the models provided by an Odoo server. - An instance of this class depends on a Connection instance with valid authentication information. - """ - - def __init__(self, connection, model_name): - """ - :param connection: A valid Connection instance with correct authentication information. - :param model_name: The name of the model. - """ - self.connection = connection - self.model_name = model_name - self.__logger = _getChildLogger(_getChildLogger(_logger, 'object'), model_name or "") - - def __getattr__(self, method): - """ - Provides proxy methods that will forward calls to the model on the remote Odoo server. - - :param method: The method for the linked model (search, read, write, unlink, create, ...) - """ - def proxy(*args, **kw): - """ - :param args: A list of values for the method - """ - self.connection.check_login(False) - self.__logger.debug(args) - result = self.connection.get_service('object').execute_kw( - self.connection.database, - self.connection.user_id, - self.connection.password, - self.model_name, - method, - args, kw) - if method == "read": - if isinstance(result, list) and len(result) > 0 and "id" in result[0]: - index = {} - for r in result: - index[r['id']] = r - if isinstance(args[0], list): - result = [index[x] for x in args[0] if x in index] - elif args[0] in index: - result = index[args[0]] - else: - result = False - self.__logger.debug('result: %r', result) - return result - return proxy - - def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None, context=None): - """ - A shortcut method to combine a search() and a read(). +_logger = logging.getLogger(__name__) - :param domain: The domain for the search. - :param fields: The fields to extract (can be None or [] to extract all fields). - :param offset: The offset for the rows to read. - :param limit: The maximum number of rows to read. - :param order: The order to class the rows. - :param context: The context. - :return: A list of dictionaries containing all the specified fields. - """ - record_ids = self.search(domain or [], offset, limit or False, order or False, context=context or {}) - if not record_ids: return [] - records = self.read(record_ids, fields or [], context=context or {}) - return records def get_connector(hostname=None, protocol="xmlrpc", port="auto"): """ @@ -365,8 +62,12 @@ def get_connector(hostname=None, protocol="xmlrpc", port="auto"): return JsonRPCConnector(hostname, port) elif protocol == "jsonrpcs": return JsonRPCSConnector(hostname, port) + elif protocol == "json2": + return Json2Connector(hostname, port) + elif protocol == "json2s": + return Json2SConnector(hostname, port) else: - raise ValueError("You must choose xmlrpc, xmlrpcs, jsonrpc or jsonrpcs") + raise ValueError("You must choose xmlrpc, xmlrpcs, jsonrpc, jsonrpcs, json2 or json2s as protocol, not %s" % protocol) def get_connection(hostname=None, protocol="xmlrpc", port='auto', database=None, login=None, password=None, user_id=None): @@ -374,14 +75,15 @@ def get_connection(hostname=None, protocol="xmlrpc", port='auto', database=None, A shortcut method to easily create a connection to a remote Odoo server. :param hostname: The hostname to the remote server. - :param protocol: The name of the protocol, must be "xmlrpc", "xmlrpcs", "jsonrpc" or "jsonrpcs". + :param protocol: The name of the protocol, must be "xmlrpc", "xmlrpcs", "jsonrpc", "jsonrpcs", "json2" or "json2s". :param port: The number of the port. Defaults to auto. :param connector: A valid Connector instance to send messages to the remote server. :param database: The name of the database to work on. :param login: The login of the user. - :param password: The password of the user. + :param password: The password of the user, or the api key. In json2 connection, this can only be an API key. :param user_id: The user id is a number identifying the user. This is only useful if you already know it, in most cases you don't need to specify it. """ + if protocol in ["json2", "json2s"]: + return Json2Connection(get_connector(hostname, protocol, port), database, password) return Connection(get_connector(hostname, protocol, port), database, login, password, user_id) - diff --git a/odoolib/rpc.py b/odoolib/rpc.py new file mode 100644 index 0000000..e8daf08 --- /dev/null +++ b/odoolib/rpc.py @@ -0,0 +1,280 @@ +""" +Odoo Client Library + +Home page: http://pypi.python.org/pypi/odoo-client-lib +Code repository: https://github.com/odoo/odoo-client-lib +""" + +import sys +if sys.version_info >= (3, 0, 0): + from xmlrpc.client import ServerProxy +else: + from xmlrpclib import ServerProxy +import logging +import json +import random + +from .tools import _getChildLogger, json_rpc, AuthenticationError, RemoteModel + +_logger = logging.getLogger(__name__) + + +class Connector(object): + """ + The base abstract class representing a connection to an Odoo Server. + """ + + __logger = _getChildLogger(_logger, 'connector') + + def get_service(self, service_name): + """ + Returns a Service instance to allow easy manipulation of one of the services offered by the remote server. + + :param service_name: The name of the service. + """ + return Service(self, service_name) + +class XmlRPCConnector(Connector): + """ + A type of connector that uses the XMLRPC protocol. + """ + PROTOCOL = 'xmlrpc' + + __logger = _getChildLogger(_logger, 'connector.xmlrpc') + + def __init__(self, hostname, port="8069"): + """ + Initialize by specifying the hostname and the port. + :param hostname: The hostname of the computer holding the instance of Odoo. + :param port: The port used by the Odoo instance for XMLRPC (default to 8069). + """ + self.url = 'http://%s:%d/xmlrpc' % (hostname, port) + + def send(self, service_name, method, *args): + url = '%s/%s' % (self.url, service_name) + service = ServerProxy(url) + return getattr(service, method)(*args) + +class XmlRPCSConnector(XmlRPCConnector): + """ + A type of connector that uses the secured XMLRPC protocol. + """ + PROTOCOL = 'xmlrpcs' + + __logger = _getChildLogger(_logger, 'connector.xmlrpcs') + + def __init__(self, hostname, port="8069"): + super(XmlRPCSConnector, self).__init__(hostname, port) + self.url = 'https://%s:%d/xmlrpc' % (hostname, port) + +class JsonRPCConnector(Connector): + """ + A type of connector that uses the JsonRPC protocol. + """ + PROTOCOL = 'jsonrpc' + + __logger = _getChildLogger(_logger, 'connector.jsonrpc') + + def __init__(self, hostname, port="8069"): + """ + Initialize by specifying the hostname and the port. + :param hostname: The hostname of the computer holding the instance of Odoo. + :param port: The port used by the Odoo instance for JsonRPC (default to 8069). + """ + self.url = 'http://%s:%d/jsonrpc' % (hostname, port) + + def send(self, service_name, method, *args): + return json_rpc(self.url, "call", {"service": service_name, "method": method, "args": args}) + +class JsonRPCSConnector(Connector): + """ + A type of connector that uses the JsonRPC protocol. + """ + PROTOCOL = 'jsonrpcs' + + __logger = _getChildLogger(_logger, 'connector.jsonrpc') + + def __init__(self, hostname, port="8069"): + """ + Initialize by specifying the hostname and the port. + :param hostname: The hostname of the computer holding the instance of Odoo. + :param port: The port used by the Odoo instance for JsonRPC (default to 8069). + """ + self.url = 'https://%s:%d/jsonrpc' % (hostname, port) + + def send(self, service_name, method, *args): + return json_rpc(self.url, "call", {"service": service_name, "method": method, "args": args}) + +class Service(object): + """ + A class to execute RPC calls on a specific service of the remote server. + """ + def __init__(self, connector, service_name): + """ + :param connector: A valid Connector instance. + :param service_name: The name of the service on the remote server. + """ + self.connector = connector + self.service_name = service_name + self.__logger = _getChildLogger(_getChildLogger(_logger, 'service'),service_name or "") + + def __getattr__(self, method): + """ + :param method: The name of the method to execute on the service. + """ + self.__logger.debug('method: %r', method) + def proxy(*args): + """ + :param args: A list of values for the method + """ + self.__logger.debug('args: %r', args) + result = self.connector.send(self.service_name, method, *args) + self.__logger.debug('result: %r', result) + return result + return proxy + +class Connection(object): + """ + A class to represent a connection with authentication to an Odoo Server. + It also provides utility methods to interact with the server more easily. + """ + __logger = _getChildLogger(_logger, 'connection') + + def __init__(self, connector, + database=None, + login=None, + password=None, + user_id=None): + """ + Initialize with login information. The login information is facultative to allow specifying + it after the initialization of this object. + + :param connector: A valid Connector instance to send messages to the remote server. + :param database: The name of the database to work on. + :param login: The login of the user. + :param password: The password of the user. + :param user_id: The user id is a number identifying the user. This is only useful if you + already know it, in most cases you don't need to specify it. + """ + self.connector = connector + + self.set_login_info(database, login, password, user_id) + self.user_context = None + + def set_login_info(self, database, login, password, user_id=None): + """ + Set login information after the initialisation of this object. + + :param connector: A valid Connector instance to send messages to the remote server. + :param database: The name of the database to work on. + :param login: The login of the user. + :param password: The password of the user. + :param user_id: The user id is a number identifying the user. This is only useful if you + already know it, in most cases you don't need to specify it. + """ + self.database, self.login, self.password = database, login, password + + self.user_id = user_id + + def check_login(self, force=True): + """ + Checks that the login information is valid. Throws an AuthenticationError if the + authentication fails. + + :param force: Force to re-check even if this Connection was already validated previously. + Default to True. + """ + if self.user_id and not force: + return + + if not self.database or not self.login or self.password is None: + raise AuthenticationError("Credentials not provided") + + # TODO use authenticate instead of login + self.user_id = self.get_service("common").login(self.database, self.login, self.password) + if not self.user_id: + raise AuthenticationError("Authentication failure") + self.__logger.debug("Authenticated with user id %s", self.user_id) + + def get_user_context(self): + """ + Query the default context of the user. + """ + if not self.user_context: + self.user_context = self.get_model('res.users').context_get() + return self.user_context + + def get_model(self, model_name): + """ + Returns a Model instance to allow easy remote manipulation of an Odoo model. + + :param model_name: The name of the model. + """ + return Model(self, model_name) + + def get_service(self, service_name): + """ + Returns a Service instance to allow easy manipulation of one of the services offered by the remote server. + Please note this Connection instance does not need to have valid authentication information since authentication + is only necessary for the "object" service that handles models. + + :param service_name: The name of the service. + """ + return self.connector.get_service(service_name) + +class Model(RemoteModel): + def __init__(self, connection, model_name): + res = super().__init__(connection, model_name) + self.__logger = _getChildLogger(_getChildLogger(_logger, 'object'), model_name or "") + return res + + def __getattr__(self, method): + """ + Provides proxy methods that will forward calls to the model on the remote Odoo server. + + :param method: The method for the linked model (search, read, write, unlink, create, ...) + """ + def proxy(*args, **kw): + """ + :param args: A list of values for the method + """ + self.connection.check_login(False) + self.__logger.debug(args) + result = self.connection.get_service('object').execute_kw( + self.connection.database, + self.connection.user_id, + self.connection.password, + self.model_name, + method, + args, kw) + if method == "read": + if isinstance(result, list) and len(result) > 0 and "id" in result[0]: + index = {} + for r in result: + index[r['id']] = r + if isinstance(args[0], list): + result = [index[x] for x in args[0] if x in index] + elif args[0] in index: + result = index[args[0]] + else: + result = False + self.__logger.debug('result: %r', result) + return result + return proxy + + def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None, context=None): + """ + A shortcut method to combine a search() and a read(). + + :param domain: The domain for the search. + :param fields: The fields to extract (can be None or [] to extract all fields). + :param offset: The offset for the rows to read. + :param limit: The maximum number of rows to read. + :param order: The order to class the rows. + :param context: The context. + :return: A list of dictionaries containing all the specified fields. + """ + record_ids = self.search(domain or [], offset, limit or False, order or False, context=context or {}) + if not record_ids: return [] + records = self.read(record_ids, fields or [], context=context or {}) + return records diff --git a/odoolib/tools.py b/odoolib/tools.py new file mode 100644 index 0000000..4670b77 --- /dev/null +++ b/odoolib/tools.py @@ -0,0 +1,51 @@ +import httpx +import logging +import random + + +def _getChildLogger(logger, subname): + return logging.getLogger(logger.name + "." + subname) + +def json_rpc(url, fct_name, params): + data = { + "jsonrpc": "2.0", + "method": fct_name, + "params": params, + "id": random.randint(0, 1000000000), + } + result_req = httpx.post(url, json=data, headers={ + "Content-Type":"application/json", + }) + result = result_req.json() + if result.get("error", None): + raise JsonRPCException(result["error"]) + return result.get("result", False) + + +class JsonRPCException(Exception): + def __init__(self, error): + self.error = error + def __str__(self): + return repr(self.error) + + +class AuthenticationError(Exception): + """ + An error thrown when an authentication to an Odoo server failed. + """ + pass + + +class RemoteModel(object): + """ + Useful class to dialog with one of the models provided by an Odoo server. + An instance of this class depends on a Connection instance with valid authentication information. + """ + + def __init__(self, connection, model_name): + """ + :param connection: A valid Connection instance with correct authentication information. + :param model_name: The name of the model. + """ + self.connection = connection + self.model_name = model_name diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..40c2f1e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" +[project] +name = "odoo-client-lib" +version = "2.0.0" +description='Odoo Client Library allows to easily interact with Odoo in Python.' +authors=[ + {name = 'Stephane Wirtel'}, + {name = 'Nicolas Vanhoren'}, + {name = 'Nicolas Seinlet', email = 'nse@odoo.com'}, +] +readme = "README.rst" +license = "BSD-3-Clause" +license-files = ["licence.txt"] +dependencies = [ + "httpx", +] +requires-python = ">= 3.9" +keywords = ["odoo", "library", "communication", "rpc", "xml-rpc", "net-rpc", "xmlrpc", "python", "client", "lib", "web", "service"] diff --git a/requirements.txt b/requirements.txt index eed6988..b634865 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -requests>=2.25.0 \ No newline at end of file +httpx>=0.23.2 \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 446cdeb..0000000 --- a/setup.py +++ /dev/null @@ -1,53 +0,0 @@ -# -*- coding: utf-8 -*- -############################################################################## -# -# Copyright (C) Stephane Wirtel -# Copyright (C) 2011 Nicolas Vanhoren -# Copyright (C) 2011 OpenERP s.a. () -# Copyright (C) 2018 Odoo s.a. (). -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -############################################################################## - -from distutils.core import setup -import os.path - - -setup(name='odoo-client-lib', - version='1.2.4', - description='Odoo Client Library allows to easily interact with Odoo in Python.', - author='Nicolas Vanhoren', - author_email='', - url='', - packages=["odoolib"], - install_requires=[ - 'requests', - ], - long_description="See the home page for any information: https://github.com/odoo/odoo-client-lib .", - keywords="odoo library com communication rpc xml-rpc net-rpc xmlrpc python client lib web service", - license="BSD", - classifiers=[ - "License :: OSI Approved :: BSD License", - "Programming Language :: Python", - ], - ) diff --git a/test.py b/test.py index d5d5882..25bc6db 100644 --- a/test.py +++ b/test.py @@ -1,42 +1,12 @@ -# -*- coding: utf-8 -*- -############################################################################## -# -# Copyright (C) Stephane Wirtel -# Copyright (C) 2011 Nicolas Vanhoren -# Copyright (C) 2011 OpenERP s.a. () -# Copyright (C) 2018 Odoo s.a. (). -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# 1. Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# 2. Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# -############################################################################## - """ Some unit tests. They assume there is an Odoo server running on localhost, on the default port -with a database named 'test' and a user 'admin' with password 'a'. +with a database named 'test' and a user 'admin' with password 'admin'. Should create an API key for json2 tests. """ +import os import odoolib import unittest -from requests.exceptions import SSLError +from httpx import ConnectError class TestSequenceFunctions(unittest.TestCase): @@ -44,14 +14,20 @@ def setUp(self): pass def _conn(self, protocol): + password = "admin" + if protocol == "json2": + password = os.environ.get("JSON2_API_KEY", "") + if not password: + raise ValueError("Environment variable 'JSON2_API_KEY' is not set.") return odoolib.get_connection(hostname="localhost", protocol=protocol, - database="test", login="admin", password="a") + database="test", login="admin", password=password) def _get_protocols(self): - return ["xmlrpc", "jsonrpc"] + return ["xmlrpc", "jsonrpc", "json2"] def _check_installed_language(self, connection, language): - res = connection.get_model("base.language.install").create({'lang': language, 'overwrite': False}) + lang_ids = connection.get_model("res.lang").search(['&', ('code', '=', language), '|', ('active', '=', True), ('active', '=', False)]) + res = connection.get_model("base.language.install").create({'lang_ids': [(6, 0, lang_ids)], 'overwrite': False}) connection.get_model("base.language.install").lang_install(res) def test_simple(self): @@ -104,7 +80,7 @@ def _ssl_connection(self): connection.get_model("res.users").read(1) def test_ensure_s_require_ssl(self): - self.assertRaises(SSLError, self._ssl_connection) + self.assertRaises(ConnectError, self._ssl_connection) if __name__ == '__main__':