# HG changeset patch
# User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr>
# Date 1734017944 -3600
#      Thu Dec 12 16:39:04 2024 +0100
# Branch 18.0
# Node ID 641c8be448fb1faca0b5576092cc0565d2954be3
# Parent  a3cfdf8a720d4f1d29982f70beda7dadbde6e86b
🔨✨ validator package does not assume a odoo.addons package name, provide full package name instead

diff --git a/NEWS.rst b/NEWS.rst
--- 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
--- 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
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -50,7 +50,8 @@
   "*.xml",
   "*.py",
   "*.svg",
-  "*.png"
+  "*.png",
+  "*.json"
 ]
 
 [tool.hatch.build.targets.wheel.sources]
diff --git a/tests/__init__.py b/tests/__init__.py
--- 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
--- /dev/null
+++ b/tests/schemas/__init__.py
@@ -0,0 +1,10 @@
+import json
+import pkgutil
+from typing import Optional, Any
+
+
+def get_schemas() -> list[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
--- /dev/null
+++ b/tests/schemas/product.schema.json
@@ -0,0 +1,71 @@
+{
+
+  "$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
--- /dev/null
+++ b/tests/schemas_dir/product.schema.json
@@ -0,0 +1,71 @@
+{
+
+  "$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
--- /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
--- a/validate.py
+++ b/validate.py
@@ -18,12 +18,15 @@
 #
 ##############################################################################
 import json
+import logging
 import os
+from collections.abc import Callable
 from enum import Enum
+from importlib import import_module
 from typing import Any, LiteralString
 
 import fastjsonschema  # type: ignore[import-untyped]
-import odoo.addons  # type: ignore[import-untyped]
+_logger = logging.getLogger(__name__)
 
 
 class Validation(str, Enum):
@@ -38,40 +41,57 @@
     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,
-        repository_module_name: str,
-        repository: str,
+        package_name: str,
         default_url_pattern: str,
+        directory: str | None = None,
     ):
-        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
-        self.validators: dict[LiteralString, Any] = {}
+        self.validators: dict[LiteralString, Callable] = {}
         self.initialized = False
         self.encoding = "UTF-8"
+        self.directory = directory
 
     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:
+            # 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.
# HG changeset patch
# User szeka_wong <szeka.wong@xcg-consulting.fr>
# Date 1734509421 -3600
#      Wed Dec 18 09:10:21 2024 +0100
# Branch 18.0
# Node ID 5556bfbd25c5a0e5ed87a123c7e806e8812f6773
# Parent  641c8be448fb1faca0b5576092cc0565d2954be3
Ruff and prettier

diff --git a/tests/schemas/product.schema.json b/tests/schemas/product.schema.json
--- a/tests/schemas/product.schema.json
+++ b/tests/schemas/product.schema.json
@@ -1,5 +1,4 @@
 {
-
   "$schema": "https://json-schema.org/draft/2020-12/schema",
 
   "$id": "https://example.com/product.schema.json",
@@ -11,61 +10,40 @@
   "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"
-
-  ]
-
+  "required": ["productId", "productName", "price"]
 }
diff --git a/tests/schemas_dir/product.schema.json b/tests/schemas_dir/product.schema.json
--- a/tests/schemas_dir/product.schema.json
+++ b/tests/schemas_dir/product.schema.json
@@ -1,5 +1,4 @@
 {
-
   "$schema": "https://json-schema.org/draft/2020-12/schema",
 
   "$id": "https://example.com/product.schema.json",
@@ -11,61 +10,40 @@
   "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"
-
-  ]
-
+  "required": ["productId", "productName", "price"]
 }
# HG changeset patch
# User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr>
# Date 1734512770 -3600
#      Wed Dec 18 10:06:10 2024 +0100
# Branch 18.0
# Node ID 6b2589f5fd7065487f1d5fe7c817d0168df43421
# Parent  5556bfbd25c5a0e5ed87a123c7e806e8812f6773
👕 prettier

diff --git a/pyproject.toml b/pyproject.toml
--- 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]
@@ -51,7 +51,7 @@
   "*.py",
   "*.svg",
   "*.png",
-  "*.json"
+  "*.json",
 ]
 
 [tool.hatch.build.targets.wheel.sources]
@@ -69,14 +69,14 @@
   "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
-"__manifest__.py" = ["B018"] # useless expression
+"__manifest__.py" = ["B018"]     # useless expression
 
 [tool.ruff.lint.mccabe]
 max-complexity = 16
@@ -89,7 +89,7 @@
   "odoo",
   "odoo-addons",
   "first-party",
-  "local-folder"
+  "local-folder",
 ]
 
 [tool.isort.sections]
# HG changeset patch
# User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr>
# Date 1734513980 -3600
#      Wed Dec 18 10:26:20 2024 +0100
# Branch 18.0
# Node ID ca9832fbd70a95e86bad1491cd4ca7724ee126d5
# Parent  6b2589f5fd7065487f1d5fe7c817d0168df43421
👕 mypy

diff --git a/tests/schemas/__init__.py b/tests/schemas/__init__.py
--- a/tests/schemas/__init__.py
+++ b/tests/schemas/__init__.py
@@ -1,9 +1,10 @@
 import json
 import pkgutil
-from typing import Optional, Any
+from typing import Any
+from collections.abc import Generator
 
 
-def get_schemas() -> list[Any]:
+def get_schemas() -> Generator[Any]:
     for file_prefix in ("product",):
         data: bytes | None = pkgutil.get_data(__name__, f"{file_prefix}.schema.json")
         if data:
# HG changeset patch
# User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr>
# Date 1734514546 -3600
#      Wed Dec 18 10:35:46 2024 +0100
# Branch 18.0
# Node ID 993af5be16eb866975e092813f6ce50a933a8dd8
# Parent  ca9832fbd70a95e86bad1491cd4ca7724ee126d5
👕 mypy: handle __file__ is none

diff --git a/validate.py b/validate.py
--- a/validate.py
+++ b/validate.py
@@ -26,6 +26,9 @@
 from typing import Any, LiteralString
 
 import fastjsonschema  # type: ignore[import-untyped]
+from odoo.exceptions import UserError  # type: ignore[import-untyped]
+
+import fastjsonschema  # type: ignore[import-untyped]
 _logger = logging.getLogger(__name__)
 
 
@@ -78,6 +81,9 @@
             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(
# HG changeset patch
# User Vincent Hatakeyama <vincent.hatakeyama@xcg-consulting.fr>
# Date 1734514965 -3600
#      Wed Dec 18 10:42:45 2024 +0100
# Branch 18.0
# Node ID a3b3b60ffcc1ccc3ea4ce98a7650d2a9508c56ad
# Parent  993af5be16eb866975e092813f6ce50a933a8dd8
👕 ruff

diff --git a/validate.py b/validate.py
--- a/validate.py
+++ b/validate.py
@@ -28,7 +28,6 @@
 import fastjsonschema  # type: ignore[import-untyped]
 from odoo.exceptions import UserError  # type: ignore[import-untyped]
 
-import fastjsonschema  # type: ignore[import-untyped]
 _logger = logging.getLogger(__name__)