diff --git a/NEWS.rst b/NEWS.rst index a3cfdf8a720d4f1d29982f70beda7dadbde6e86b_TkVXUy5yc3Q=..a3b3b60ffcc1ccc3ea4ce98a7650d2a9508c56ad_TkVXUy5yc3Q= 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,6 +1,11 @@ Changelog ========= +18.0.3.0.0 +---------- + +Breaking change: validator package does not assume a odoo.addons package name, provide full package name instead. + 18.0.2.2.0 ---------- diff --git a/__manifest__.py b/__manifest__.py index a3cfdf8a720d4f1d29982f70beda7dadbde6e86b_X19tYW5pZmVzdF9fLnB5..a3b3b60ffcc1ccc3ea4ce98a7650d2a9508c56ad_X19tYW5pZmVzdF9fLnB5 100644 --- a/__manifest__.py +++ b/__manifest__.py @@ -21,7 +21,7 @@ "name": "Converter", "license": "AGPL-3", "summary": "Convert odoo records to/from plain data structures.", - "version": "18.0.2.2.0", + "version": "18.0.3.0.0", "category": "Hidden", "author": "XCG Consulting", "website": "https://orbeet.io/", diff --git a/pyproject.toml b/pyproject.toml index a3cfdf8a720d4f1d29982f70beda7dadbde6e86b_cHlwcm9qZWN0LnRvbWw=..a3b3b60ffcc1ccc3ea4ce98a7650d2a9508c56ad_cHlwcm9qZWN0LnRvbWw= 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ "Programming Language :: Python :: 3.11", "Framework :: Odoo", "Framework :: Odoo :: 18.0", - "License :: OSI Approved :: GNU Affero General Public License v3" + "License :: OSI Approved :: GNU Affero General Public License v3", ] dependencies = ["odoo==18.0.*", "fastjsonschema"] @@ -37,7 +37,7 @@ "/.hgignore", "/.hgtags", "/prettier.config.cjs", - "/.yamllint.yaml" + "/.yamllint.yaml", ] [tool.hatch.build.targets.wheel] @@ -50,7 +50,8 @@ "*.xml", "*.py", "*.svg", - "*.png" + "*.png", + "*.json", ] [tool.hatch.build.targets.wheel.sources] @@ -68,10 +69,10 @@ "B", "C90", "E501", # line too long (default 88) - "I", # isort - "UP", # pyupgrade + "I", # isort + "UP", # pyupgrade ] [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401", "I001"] # ignore unused and unsorted imports in __init__.py @@ -73,9 +74,9 @@ ] [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401", "I001"] # ignore unused and unsorted imports in __init__.py -"__manifest__.py" = ["B018"] # useless expression +"__manifest__.py" = ["B018"] # useless expression [tool.ruff.lint.mccabe] max-complexity = 16 @@ -88,7 +89,7 @@ "odoo", "odoo-addons", "first-party", - "local-folder" + "local-folder", ] [tool.isort.sections] diff --git a/tests/__init__.py b/tests/__init__.py index a3cfdf8a720d4f1d29982f70beda7dadbde6e86b_dGVzdHMvX19pbml0X18ucHk=..a3b3b60ffcc1ccc3ea4ce98a7650d2a9508c56ad_dGVzdHMvX19pbml0X18ucHk= 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -6,4 +6,5 @@ test_mail_template, test_relation, test_switch, + test_validate, ) diff --git a/tests/schemas/__init__.py b/tests/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a3b3b60ffcc1ccc3ea4ce98a7650d2a9508c56ad_dGVzdHMvc2NoZW1hcy9fX2luaXRfXy5weQ== --- /dev/null +++ b/tests/schemas/__init__.py @@ -0,0 +1,11 @@ +import json +import pkgutil +from typing import Any +from collections.abc import Generator + + +def get_schemas() -> Generator[Any]: + for file_prefix in ("product",): + data: bytes | None = pkgutil.get_data(__name__, f"{file_prefix}.schema.json") + if data: + yield json.loads(data) diff --git a/tests/schemas/product.schema.json b/tests/schemas/product.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..a3b3b60ffcc1ccc3ea4ce98a7650d2a9508c56ad_dGVzdHMvc2NoZW1hcy9wcm9kdWN0LnNjaGVtYS5qc29u --- /dev/null +++ b/tests/schemas/product.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + + "$id": "https://example.com/product.schema.json", + + "title": "Product", + + "description": "A product from Acme's catalog", + + "type": "object", + + "properties": { + "productId": { + "description": "The unique identifier for a product", + + "type": "integer" + }, + + "productName": { + "description": "Name of the product", + + "type": "string" + }, + + "price": { + "description": "The price of the product", + + "type": "number", + + "exclusiveMinimum": 0 + }, + + "tags": { + "description": "Tags for the product", + + "type": "array", + + "items": { + "type": "string" + }, + + "minItems": 1, + + "uniqueItems": true + } + }, + + "required": ["productId", "productName", "price"] +} diff --git a/tests/schemas_dir/product.schema.json b/tests/schemas_dir/product.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..a3b3b60ffcc1ccc3ea4ce98a7650d2a9508c56ad_dGVzdHMvc2NoZW1hc19kaXIvcHJvZHVjdC5zY2hlbWEuanNvbg== --- /dev/null +++ b/tests/schemas_dir/product.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + + "$id": "https://example.com/product.schema.json", + + "title": "Product", + + "description": "A product from Acme's catalog", + + "type": "object", + + "properties": { + "productId": { + "description": "The unique identifier for a product", + + "type": "integer" + }, + + "productName": { + "description": "Name of the product", + + "type": "string" + }, + + "price": { + "description": "The price of the product", + + "type": "number", + + "exclusiveMinimum": 0 + }, + + "tags": { + "description": "Tags for the product", + + "type": "array", + + "items": { + "type": "string" + }, + + "minItems": 1, + + "uniqueItems": true + } + }, + + "required": ["productId", "productName", "price"] +} diff --git a/tests/test_validate.py b/tests/test_validate.py new file mode 100644 index 0000000000000000000000000000000000000000..a3b3b60ffcc1ccc3ea4ce98a7650d2a9508c56ad_dGVzdHMvdGVzdF92YWxpZGF0ZS5weQ== --- /dev/null +++ b/tests/test_validate.py @@ -0,0 +1,64 @@ +############################################################################## +# +# Converter Odoo module +# Copyright © 2024 XCG Consulting <https://xcg-consulting.fr> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +############################################################################## +import json + +from odoo import tests # type: ignore[import-untyped] + +from ..validate import Validator + + +class TestValidate(tests.TransactionCase): + def test_validate(self): + validator = Validator( + "odoo.addons.converter.tests.schemas", "https://example.com/{}.schema.json" + ) + validator.initialize() + validator.validate( + "product", + json.loads("""{ + "productId": 1, + "productName": "An ice sculpture", + "price": 12.5, + "tags": [ + "cold", + "ice" + ] +}"""), + ) + + def test_validate_dir(self): + validator = Validator( + "odoo.addons.converter.tests", + "https://example.com/{}.schema.json", + "schemas_dir", + ) + validator.initialize() + validator.validate( + "product", + json.loads("""{ + "productId": 1, + "productName": "An ice sculpture", + "price": 12.5, + "tags": [ + "cold", + "ice" + ] +}"""), + ) diff --git a/validate.py b/validate.py index a3cfdf8a720d4f1d29982f70beda7dadbde6e86b_dmFsaWRhdGUucHk=..a3b3b60ffcc1ccc3ea4ce98a7650d2a9508c56ad_dmFsaWRhdGUucHk= 100644 --- a/validate.py +++ b/validate.py @@ -18,4 +18,5 @@ # ############################################################################## import json +import logging import os @@ -21,2 +22,3 @@ import os +from collections.abc import Callable from enum import Enum @@ -22,4 +24,5 @@ from enum import Enum +from importlib import import_module from typing import Any, LiteralString import fastjsonschema # type: ignore[import-untyped] @@ -23,7 +26,9 @@ from typing import Any, LiteralString import fastjsonschema # type: ignore[import-untyped] -import odoo.addons # type: ignore[import-untyped] +from odoo.exceptions import UserError # type: ignore[import-untyped] + +_logger = logging.getLogger(__name__) class Validation(str, Enum): @@ -38,6 +43,13 @@ pass +def _add_schema(schemas, schema): + if "$id" in schema: + schemas[schema["$id"]] = schema + else: + _logger.warning("Schema without $id (schema ignored)") + + class Validator: def __init__( self, @@ -41,6 +53,5 @@ class Validator: def __init__( self, - repository_module_name: str, - repository: str, + package_name: str, default_url_pattern: str, @@ -46,2 +57,3 @@ default_url_pattern: str, + directory: str | None = None, ): @@ -47,5 +59,10 @@ ): - self.repository_module_name = repository_module_name - self.repository = repository + """ + :param package_name: Package where the schema can be found + :param default_url_pattern: pattern for url ({} will be replaced by $id) + :param directory: directory to search for json, not used if a get_schema is + provided in the package. + """ + self.package_name = package_name # exemple "https://annonces-legales.fr/xbus/schemas/v1/{}.schema.json" self.default_url_pattern = default_url_pattern @@ -50,5 +67,5 @@ # exemple "https://annonces-legales.fr/xbus/schemas/v1/{}.schema.json" self.default_url_pattern = default_url_pattern - self.validators: dict[LiteralString, Any] = {} + self.validators: dict[LiteralString, Callable] = {} self.initialized = False self.encoding = "UTF-8" @@ -53,4 +70,5 @@ self.initialized = False self.encoding = "UTF-8" + self.directory = directory def initialize(self) -> None: @@ -55,23 +73,30 @@ def initialize(self) -> None: - # TODO Not working if module is installed compressed - repo_module_basepath = os.path.dirname( - getattr(odoo.addons, self.repository_module_name).__file__ - ) - - # Read local schema definitions. - schemas = {} - schema_search_path = os.path.abspath( - os.path.join(repo_module_basepath, self.repository) - ) - for root, _dirs, files in os.walk(schema_search_path): - for fname in files: - fpath = os.path.join(root, fname) - if fpath.endswith((".json",)): - with open(fpath, encoding=self.encoding) as schema_fd: - schema = json.load(schema_fd) - if "$id" in schema: - schemas[schema["$id"]] = schema + if self.initialized: + return + schemas: dict[LiteralString, Any] = {} + module = import_module(self.package_name) + if hasattr(module, "get_schemas"): + for schema in module.get_schemas(): + _add_schema(schemas, schema) + else: + if module.__file__ is None: + # XXX maybe not the best type of error + raise UserError("Module %s has no file", self.package_name) + # Fallback on searching schema json files + schema_search_path = os.path.dirname(module.__file__) + schema_search_path = os.path.abspath( + os.path.join(schema_search_path, self.directory) + if self.directory is not None + else schema_search_path + ) + for root, _dirs, files in os.walk(schema_search_path): + for fname in files: + fpath = os.path.join(root, fname) + if fpath.endswith((".json",)): + with open(fpath, encoding=self.encoding) as schema_fd: + schema = json.load(schema_fd) + _add_schema(schemas, schema) # Prepare validators for each schema. We add an HTTPS handler that # points back to our schema definition cache built above.