diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5ab882c..cf53858 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -26,11 +26,9 @@ }, "extensions": [ "ms-python.python", - "ms-python.black-formatter", - "ms-python.flake8", - "ms-python.isort", - "matangover.mypy", - "ms-python.vscode-pylance" + "ms-python.vscode-pylance", + "charliermarsh.ruff", + "matangover.mypy" ] } }, diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 74877bf..0000000 --- a/.flake8 +++ /dev/null @@ -1,23 +0,0 @@ -[flake8] -# * Complexity -# C901: complexity check (mccabe) -# * Errors & style (https://flake8.pycqa.org/en/latest/user/error-codes.html) -# E123,E133: ignored by default by pep8, so we ignore them here -# E203: space before comma, not pep8 compliant -# E241,E242: spacing after a comma - not used -# F401: module imported but unused -# F812: list comprehension redefines name -# W503: line break before operator - ignored as W504 is in pep8 -# * Documentation (https://pep257.readthedocs.io/en/latest/error_codes.html) -# D100: missing doc in module -# D101: missing doc in class -# D102,D103: missing doc in public method, function -# D205: 1 blank line required between summary line and description -# D210: no whitespaces allowed surrounding docstring text - not used -# D400: first line should end with a period -ignore = C901,E123,E133,E203,F812,W503,D102,D205,D400 -max-line-length = 100 -exclude = .venv,venv,.git -max-complexity = 10 -per-file-ignores = - #**/__init__.py:F401 diff --git a/.github/workflows/python-lint-test.yml b/.github/workflows/python-lint-test.yml index dccbdaf..c79aa17 100644 --- a/.github/workflows/python-lint-test.yml +++ b/.github/workflows/python-lint-test.yml @@ -13,11 +13,10 @@ jobs: python-version: "3.10" - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install -r requirements.txt + python -m pip install --upgrade -e .[pinned,dev] - name: Run pre-commit checks run: | - ./pre-commit + ./pre-commit lint test: runs-on: ubuntu-latest strategy: @@ -32,8 +31,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install -r requirements.txt + python -m pip install --upgrade -e .[pinned,dev,test] - name: Run pytest run: | pytest --doctest-modules -m "not runbot" diff --git a/.gitignore b/.gitignore index 5a3d5a5..c915993 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,5 @@ __pycache__ dist .ipynb_checkpoints *.egg-info -.mypy_cache -.pytest_cache +.*_cache demo*.ipynb diff --git a/README.md b/README.md index f2ed72c..0257e6a 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Alternatively, clone and setup the repository manually. git clone $url cd odoo-connect # Install dev libraries -pip install -r requirements.txt +pip install -e .[pinned,dev,test] ./pre-commit install # Run some tests pytest diff --git a/odoo_connect/__init__.py b/odoo_connect/__init__.py index aa909df..4dc13e8 100644 --- a/odoo_connect/__init__.py +++ b/odoo_connect/__init__.py @@ -2,7 +2,7 @@ import urllib.parse from typing import Optional -from .odoo_rpc import OdooClient, OdooModel, OdooServerError # noqa +from .odoo_rpc import OdooClient, OdooModel, OdooServerError __doc__ = """Simple Odoo RPC library.""" diff --git a/odoo_connect/attachment.py b/odoo_connect/attachment.py index 89404c4..a773f52 100644 --- a/odoo_connect/attachment.py +++ b/odoo_connect/attachment.py @@ -53,7 +53,7 @@ def get_attachment( return b'' value = value[0] elif field_info.get("type") != "binary": - raise RuntimeError("%s is neither a binary or an ir.attachment field" % field_name) + raise RuntimeError(f"{field_name} is neither a binary or an ir.attachment field") if isinstance(value, int): return get_attachment(model, value) return decode_binary(value) @@ -96,10 +96,7 @@ def list_attachments( if model.model == 'ir.attachment': attachments = model data = attachments.search_read( - [ - ('id', 'in', ids), - ] - + domain, + [('id', 'in', ids), *domain], fields, ) else: @@ -109,8 +106,8 @@ def list_attachments( ('res_model', '=', model.model), ('res_id', 'in', ids), ('id', '!=', 0), # to get all res_field - ] - + domain, + *domain, + ], fields, ) # Get contents diff --git a/odoo_connect/data.py b/odoo_connect/data.py index eed6f57..9f4aaa4 100644 --- a/odoo_connect/data.py +++ b/odoo_connect/data.py @@ -89,15 +89,15 @@ def load_data( formatter = Formatter(model) log.info("Load: convert and format data") - if method_row_type == dict: + if method_row_type is dict: data = __convert_to_type_dict(data, fields) data = [formatter.format_dict(d) for d in data] - elif method_row_type == list: + elif method_row_type is list: data, fields = __convert_to_type_list(data, fields) formatter_functions = [formatter.format_function[f] for f in fields] data = [[ff(v) for ff, v in zip(formatter_functions, d)] for d in data] else: - raise Exception('Unsupported method row type: %s' % method_row_type) + raise Exception(f'Unsupported method row type: {method_row_type}') log.info("Load data using %s.%s(), %d records", model.model, method, len(data)) if method == 'load': @@ -126,7 +126,7 @@ def __convert_to_type_list( fields = first_row data = idata elif not isinstance(data, list): - data = [first_row] + list(data) + data = [first_row, *list(data)] return data, fields @@ -141,11 +141,11 @@ def __convert_to_type_dict(data: Iterable, fields: Optional[list[str]]) -> Itera data = (dict(zip(fields, d)) for d in idata) else: if not isinstance(data, list): - data = [first_row] + list(idata) + data = [first_row, *list(idata)] if fields: data = ({f: d[i] for i, f in enumerate(fields)} for d in data) elif not isinstance(data, list): - data = [first_row] + list(idata) + data = [first_row, *list(idata)] return data @@ -371,7 +371,7 @@ def add_fields( """ domain_by_field: list[Any] = [(by_field, 'in', [d[by_field] for d in data])] domain = ['&'] + domain_by_field + (domain if domain else domain_by_field) - fetched_data = model.search_read_dict(domain, fields + [by_field]) + fetched_data = model.search_read_dict(domain, [*fields, by_field]) index = {d.pop(by_field): d for d in fetched_data} if len(index) != len(fetched_data): raise Exception(f'{by_field} is not unique in {model.model} when adding fields') @@ -405,7 +405,7 @@ def add_xml_id(model: OdooModel, data: list, *, id_name='id', xml_id_field='xml_ else: ids.add(row[cast(int, id_index)]) else: - raise TypeError('Cannot append the url to %s' % type(row)) + raise TypeError(f'Cannot append the url to {type(row)}') xml_ids = { i['res_id']: i['complete_name'] for i in model.odoo['ir.model.data'].search_read( @@ -445,9 +445,9 @@ def add_url( base_url = model.odoo.url if not model_id_func and data: if isinstance(data[0], list): - model_id_func = lambda r: (model.model, r[0]) # noqa: E731 + model_id_func = lambda r: (model.model, r[0]) else: - model_id_func = lambda d: (model.model, d['id']) # noqa: E731 + model_id_func = lambda d: (model.model, d['id']) for row_num, row in enumerate(data): model_name, id = model_id_func(row) url = build_url(row, model_name, id) @@ -461,7 +461,7 @@ def add_url( else: row.append(url) else: - raise TypeError('Cannot append the url to %s' % type(row)) + raise TypeError(f'Cannot append the url to {type(row)}') def _flatten(value, access: list[str]) -> Any: diff --git a/odoo_connect/odoo_rpc.py b/odoo_connect/odoo_rpc.py index 78d186f..8d48204 100644 --- a/odoo_connect/odoo_rpc.py +++ b/odoo_connect/odoo_rpc.py @@ -110,7 +110,7 @@ def _find_default_database(self, *, monodb=True) -> str: # Fail or default if self.database: return self.database - raise OdooServerError('Cannot determine the database for [%s]' % self.url) + raise OdooServerError(f'Cannot determine the database for [{self.url}]') def authenticate(self, username: str, password: str): """Authenticate with username and password""" @@ -121,7 +121,7 @@ def authenticate(self, username: str, password: str): self._password = password if not username: if old_username: - log.info('Logged out [%s]' % self.url) + log.info(f'Logged out [{self.url}]') return if not self._database: raise OdooServerError('Missing database to connect') @@ -135,7 +135,7 @@ def authenticate(self, username: str, password: str): user_agent_env, ) if not self._uid: - raise OdooServerError('Failed to authenticate user %s' % username) + raise OdooServerError(f'Failed to authenticate user {username}') log.info("Login successful [%s], [%s] uid: %d", self.url, self.username, self._uid) def _json_rpc(self, method: str, params: Any): @@ -191,7 +191,7 @@ def get_model(self, model_name: str, check: bool = False) -> "OdooModel": # let's fetch the fields (which we probably will do anyways) model.fields() except: # noqa: E722 - raise OdooServerError('Model %s not found' % model) + raise OdooServerError(f'Model {model} not found') return model def list_databases(self) -> list[str]: @@ -221,9 +221,7 @@ def ref( if to_return: return to_return[0] if raise_if_not_found: - raise ValueError( - 'No record found for unique ID %s. It may have been deleted.' % (xml_id) - ) + raise ValueError(f'No record found for unique ID {xml_id}. It may have been deleted.') return {} def version(self) -> dict: @@ -368,7 +366,7 @@ def __prepare_dict_fields(self, fields: Union[list[str], dict[str, dict]]) -> di new_fields.update({k: v for k, v in fields.items() if k not in new_fields}) return new_fields return fields - raise ValueError('Invalid fields parameter: %s' % fields) + raise ValueError(f'Invalid fields parameter: {fields}') def __read_dict_date(self, data, fields): """Transform dates into ISO-like format""" diff --git a/pre-commit b/pre-commit index 79f3daa..bdb5576 100755 --- a/pre-commit +++ b/pre-commit @@ -2,34 +2,46 @@ set -eu # Check directory cd "$(dirname "$0")" -[[ "$0" != *.git/hooks/* ]] || cd ../.. +[ -d .git ] || cd ../.. +[ -d .git ] -pre_commit() { - flake8 - black --check . - isort --check-only . +check() { + echo " ruff:" + ruff check + ruff format --check } lint() { - pre_commit - mypy --install-types --non-interactive . + check + echo " mypy:" + mypy . +} + +fix() { + ruff check --fix-only . } format() { - black . - isort . + ruff format } # Commands case "${1:-run}" in - run) - pre_commit - echo "All good to commit" + run|check) + check + echo " all good to commit." ;; lint) - lint;; + lint + ;; + fix) + echo "Fix all..." + fix + format + ;; format) - format;; + format + ;; install) echo "Installing pre-commit" cd .git/hooks @@ -41,5 +53,5 @@ case "${1:-run}" in ;; *) echo "Invalid argument: $*" - echo "Supported options: lint, install, uninstall" + echo "Supported options: lint, fix, format, install, uninstall" esac diff --git a/pyproject.toml b/pyproject.toml index 6b64293..67a258d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,17 @@ [build-system] requires = [ - "setuptools>=61", - "setuptools-scm>=6", + "setuptools>=70", + "setuptools-scm>=8", "wheel", ] build-backend = "setuptools.build_meta" +[tools.setuptools] +packages = ["odoo_connect"] + +[tool.setuptools_scm] +local_scheme = "no-local-version" + [project] name = "odoo-connect" dynamic = ["version"] @@ -25,6 +31,23 @@ dependencies = [ "requests", ] +[project.optional-dependencies] +pinned = [ + "requests==2.32.3", + "charset-normalizer==3.3.2", + "idna==3.7", +] +dev = [ + "mypy~=1.11", + "ruff==0.5.6", + "types-requests~=2.31.0", + "ipython>=8", +] +test = [ + "pytest==8.3.2", + "pytest-httpserver==1.0.12", +] + [project.urls] Homepage = "https://github.com/kmagusiak/odoo-connect" @@ -36,15 +59,56 @@ email = "chrmag@poczta.onet.pl" line-length = 100 skip-string-normalization = 1 -[tool.mypy] -ignore_missing_imports = true - [tool.isort] profile = "black" line_length = 100 -[tools.setuptools] -packages = ["odoo_connect"] +[tool.mypy] +ignore_missing_imports = true -[tool.setuptools_scm] -local_scheme = "no-local-version" +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.format] +quote-style="preserve" + +[tool.ruff.lint] +# https://beta.ruff.rs/docs/rules/ +select = [ + "C4", # flake8 comprehensions + #"C9", # mccabe + "COM", # flake8 commas + #"D", # pydocstyle, pydoclint + "E", # pycodestyle + "EXE", # flake8-executable + "F", # pyflakes + "I", # isort + "LOG", # flake8 logging + "N", # naming + "PLE", # pylint errors + "RET", # flake8 return + "RUF", # ruff specific + "SIM", # flake8 simplify + "TID", # flake8 tidy imports + "UP", # pyupdate + "W", # pycodestyle + # specific rules + "FIX003" # comments with XXX should become TODO or FIXME +] +ignore = [ + "COM812", # trailing commas (because we use the ruff formatter) + "D102", # mission doc in public method, function + "D205", # blank line required between summary and description + "D400", # first line should end with a period + "E731", # don't assign lambda + "SIM108", # simplify ITE by operator + "SIM300", # yoda condition + "UP038", # isinstance must use union operator on types +] + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] diff --git a/requirements.txt b/requirements.txt index fd12cc2..70916ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,74 @@ -# Build tools -setuptools -setuptools_scm -black -flake8 -isort -mypy -types-requests - -# Testing -ipython -pytest -pytest-httpserver - -# Dependencies -requests +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --all-extras +# +asttokens==2.4.1 + # via stack-data +certifi==2024.7.4 + # via requests +charset-normalizer==3.3.2 + # via requests +decorator==5.1.1 + # via ipython +executing==2.0.1 + # via stack-data +idna==3.7 + # via requests +iniconfig==2.0.0 + # via pytest +ipython==8.26.0 + # via odoo-connect (pyproject.toml) +jedi==0.19.1 + # via ipython +markupsafe==2.1.5 + # via werkzeug +matplotlib-inline==0.1.7 + # via ipython +mypy==1.11.1 + # via odoo-connect (pyproject.toml) +mypy-extensions==1.0.0 + # via mypy +packaging==24.1 + # via pytest +parso==0.8.4 + # via jedi +pexpect==4.9.0 + # via ipython +pluggy==1.5.0 + # via pytest +prompt-toolkit==3.0.47 + # via ipython +ptyprocess==0.7.0 + # via pexpect +pure-eval==0.2.3 + # via stack-data +pygments==2.18.0 + # via ipython +pytest==8.3.2 + # via odoo-connect (pyproject.toml) +pytest-httpserver==1.0.12 + # via odoo-connect (pyproject.toml) +requests==2.32.3 + # via odoo-connect (pyproject.toml) +ruff==0.5.6 + # via odoo-connect (pyproject.toml) +six==1.16.0 + # via asttokens +stack-data==0.6.3 + # via ipython +traitlets==5.14.3 + # via + # ipython + # matplotlib-inline +typing-extensions==4.12.2 + # via + # ipython + # mypy +urllib3==2.2.2 + # via requests +wcwidth==0.2.13 + # via prompt-toolkit +werkzeug==3.0.3 + # via pytest-httpserver diff --git a/tests/mock_odoo_server.py b/tests/mock_odoo_server.py index d40278a..a0b060e 100644 --- a/tests/mock_odoo_server.py +++ b/tests/mock_odoo_server.py @@ -63,6 +63,7 @@ def _patch_execute_kw(f): def checked_f(model_, function_, a, kw): if model == model_ and function == function_: return f(*a, **kw) + return None self.call_execute_kw.append(checked_f) return checked_f @@ -104,11 +105,12 @@ def version(service, method, args): "server_serie": "16.0", "protocol_version": "1", } + return None @h.patch_generic def authenticate(service, method, args): if method == 'login': - args = args + [{}] + args = [*args, {}] method = 'authenticate' if service == 'common' and method == 'authenticate': database, username, password, env = args @@ -117,6 +119,7 @@ def authenticate(service, method, args): if username and username == password: return 2 raise OdooMockedError(f'Cannot authenticate on {database} with {username}') + return None @h.patch_execute_kw('res.users', 'fields_get') def field_get_user(allfields=[], attributes=[]): @@ -156,7 +159,7 @@ def read_user(id, fields=[], load=None): 'parnter_id': [id, 'Contact'], } if fields: - fields = ['id'] + fields + fields = ["id", *fields] result = {k: v for k, v in result.items() if k in fields} return [result] diff --git a/tests/test_connect.py b/tests/test_connect.py index d15dd5a..f7d6677 100644 --- a/tests/test_connect.py +++ b/tests/test_connect.py @@ -29,6 +29,7 @@ def test_list_databases(odoo_cli, odoo_json_rpc_handler): def list_db(service, method, args): if service == 'db' and method == 'list': return ['odoo'] + return None databases = odoo_cli.list_databases() assert databases == ['odoo'] diff --git a/tests/test_data.py b/tests/test_data.py index 0eebb66..347f08d 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -22,7 +22,7 @@ def read_search_partner(domain, fields=[], load=None): @handler.patch_execute_kw('res.partner', 'fields_get') def read_fields_partner(allfields=[], attributes=[]): - attr = {a: True if a == 'store' else False for a in attributes} + attr = {a: a == 'store' for a in attributes} return { 'id': {**attr, 'type': 'int', 'string': 'ID'}, 'name': {**attr, 'type': 'char', 'string': 'Name'}, @@ -87,7 +87,7 @@ def read_search_data(domain, fields=[], load=None): ) def test_flatten(dict, fields, expected): result = odoo_data.flatten([dict], fields) - output = list(result)[0] + output = next(iter(result)) assert expected == output diff --git a/tests/test_explore.py b/tests/test_explore.py index b2d6d01..c58be07 100644 --- a/tests/test_explore.py +++ b/tests/test_explore.py @@ -14,7 +14,7 @@ def read_partner(ids, fields=[], load=None): if not fields: fields = data[0].keys() elif 'id' not in fields: - fields = ['id'] + fields + fields = ['id', *fields] return [{k: v for k, v in d.items() if k in fields} for d in data if d['id'] in ids] handler.patch_execute_kw('res.partner', 'read')(read_partner) diff --git a/tests/test_format.py b/tests/test_format.py index d8f4d6f..38565ed 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -38,7 +38,7 @@ def test_binary_encoding(): def test_format(type_name, input, expected): ff = odoo_format._FORMAT_FUNCTIONS.get(type_name) formatter = ff[0] if ff else odoo_format.format_default - assert formatter(input) == expected, "Couldn't format %s" % type_name + assert formatter(input) == expected, f"Couldn't format {type_name}" @pytest.mark.parametrize( @@ -58,7 +58,7 @@ def test_format(type_name, input, expected): def test_decode(type_name, input, expected): ff = odoo_format._FORMAT_FUNCTIONS.get(type_name) decoder = ff[1] if ff else odoo_format.decode_default - assert decoder(input) == expected, "Couldn't decode %s" % type_name + assert decoder(input) == expected, f"Couldn't decode {type_name}" def test_formatter():