From 191c61d032c2b3e3501c7545bbd8a29e181eb03e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sun, 22 Feb 2026 18:01:47 +0100 Subject: [PATCH] [IMP] odoo_repository: replace oca-port dependency in tests And cache generated git repository in memory to speed up tests execution. --- odoo_repository/tests/common.py | 20 +-- odoo_repository/tests/odoo_repo_mixin.py | 160 ++++++++++++++++++ odoo_repository/tests/test_base_scanner.py | 2 +- .../tests/test_odoo_repository_scan.py | 5 +- .../tests/test_repository_scanner.py | 2 +- 5 files changed, 173 insertions(+), 16 deletions(-) create mode 100644 odoo_repository/tests/odoo_repo_mixin.py diff --git a/odoo_repository/tests/common.py b/odoo_repository/tests/common.py index 2242a1d..4de3429 100644 --- a/odoo_repository/tests/common.py +++ b/odoo_repository/tests/common.py @@ -1,4 +1,5 @@ # Copyright 2024 Camptocamp SA +# Copyright 2026 Sébastien Alix # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import os @@ -8,16 +9,16 @@ from unittest.mock import patch import git -from oca_port.tests.common import CommonCase from odoo.tests.common import TransactionCase +from .odoo_repo_mixin import OdooRepoMixin -class Common(TransactionCase, CommonCase): + +class Common(TransactionCase, OdooRepoMixin): @classmethod def setUpClass(cls): super().setUpClass() - CommonCase.setUpClass() cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) cls.repositories_path = tempfile.mkdtemp() cls.env["ir.config_parameter"].set_param( @@ -27,9 +28,6 @@ def setUpClass(cls): def setUp(self): super().setUp() - # Leverage the existing test class from 'oca_port' to bootstrap - # temporary git repositories to run tests - CommonCase.setUp(self) self.repo_name = pathlib.Path(self.repo_upstream_path).parts[-1] self.org = self.env["odoo.repository.org"].create({"name": self.fork_org}) self.odoo_repository = self.env["odoo.repository"].create( @@ -85,13 +83,11 @@ def _apply_git_config(cls): os.system("git config --global user.name 'test'") def _patch_github_class(self): - res = super()._patch_github_class() - # Patch helper method part of 'odoo_repository' module as well - self.patcher2 = patch("odoo.addons.odoo_repository.utils.github.request") - github_request = self.patcher2.start() + # Patch helper method part of 'odoo_repository' module + self.patcher = patch("odoo.addons.odoo_repository.utils.github.request") + github_request = self.patcher.start() github_request.return_value = {} - self.addCleanup(self.patcher2.stop) - return res + self.addCleanup(self.patcher.stop) def _update_module_version_on_branch(self, branch, version): """Change module version on a given branch, and commit the change.""" diff --git a/odoo_repository/tests/odoo_repo_mixin.py b/odoo_repository/tests/odoo_repo_mixin.py new file mode 100644 index 0000000..b9cd7bc --- /dev/null +++ b/odoo_repository/tests/odoo_repo_mixin.py @@ -0,0 +1,160 @@ +# Copyright 2026 Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import io +import shutil +import tempfile +import threading +import time +import unittest +import zipfile +from pathlib import Path + +import git + +cache = threading.local() + + +class OdooRepoMixin(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.upstream_org = "ORG" + cls.fork_org = "FORK" + cls.repo_name = "test" + cls.source1 = "origin/15.0" + cls.source2 = "origin/16.0" + cls.target1 = "origin/16.0" + cls.target2 = "origin/17.0" + cls.target3 = "origin/18.0" + cls.addon = "my_module" + cls.target_addon = "my_module_renamed" + + def setUp(self): + super().setUp() + # Create a temporary Git repository + self.repo_upstream_path = self._get_upstream_repository_path() + self.addon_path = Path(self.repo_upstream_path) / self.addon + self.manifest_path = self.addon_path / "__manifest__.py" + # By cloning the first repository this will set an 'origin' remote + self.repo_path = self._clone_tmp_git_repository(self.repo_upstream_path) + self._add_fork_remote(self.repo_path) + + def _get_upstream_repository_path(self) -> Path: + """Returns the path of upstream repository. + + Generate the upstream git repository or re-use the one put in cache if any. + """ + if hasattr(cache, "archive_data") and cache.archive_data: + # Unarchive the repository from memory + repo_path = self._unarchive_upstream_repository(cache.archive_data) + else: + # Prepare and archive the repository in memory + repo_path = self._create_tmp_git_repository() + addon_path = repo_path / self.addon + self._fill_git_repository(repo_path, addon_path) + cache.archive_data = self._archive_upstream_repository(repo_path) + return repo_path + + def _archive_upstream_repository(self, repo_path: Path) -> bytes: + """Archive the repository located at `repo_path`. + + Returns binary value of the archive. + """ + # Create in-memory zip archive + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zipf: + for file_path in repo_path.rglob("*"): + if file_path.is_file(): + arcname = file_path.relative_to(repo_path) + zipf.write(file_path, arcname) + return zip_buffer.getvalue() + + def _unarchive_upstream_repository(self, archive_data: bytes) -> Path: + """Unarchive the repository contained in `archive_data`. + + Returns path of repository. + """ + temp_dir = tempfile.mkdtemp() + with zipfile.ZipFile(io.BytesIO(archive_data), "r") as zip_ref: + zip_ref.extractall(temp_dir) + # Look for the repo directory and return its path + for path in Path(temp_dir).rglob("*"): + if path.is_dir() and ".git" in path.name: + return path.parent + + def _create_tmp_git_repository(self) -> Path: + """Create a temporary Git repository to run tests.""" + repo_path = tempfile.mkdtemp() + git.Repo.init(repo_path) + return Path(repo_path) + + def _clone_tmp_git_repository(self, upstream_path: Path) -> Path: + repo_path = tempfile.mkdtemp() + git.Repo.clone_from(upstream_path, repo_path) + return Path(repo_path) + + def _fill_git_repository(self, repo_path: Path, addon_path: Path): + """Create branches with some content in the Git repository.""" + repo = git.Repo(repo_path) + # Commit a file in '15.0' + branch1 = self.source1.split("/")[1] + repo.git.checkout("--orphan", branch1) + self._create_module(addon_path) + repo.index.add(addon_path) + commit = repo.index.commit(f"[ADD] {self.addon}") + # Port the commit from 15.0 to 16.0 + branch2 = self.source2.split("/")[1] + repo.git.checkout("--orphan", branch2) + repo.git.reset("--hard") + # Some git operations do not appear to be atomic, so a delay is added + # to allow them to complete + time.sleep(1) + repo.git.cherry_pick(commit.hexsha) + # Create an empty branch 17.0 + branch3 = self.target2.split("/")[1] + repo.git.checkout("--orphan", branch3) + repo.git.reset("--hard") + repo.git.commit("-m", "Init", "--allow-empty") + # Port the commit from 15.0 to 18.0 + branch4 = self.target3.split("/")[1] + repo.git.checkout("--orphan", branch4) + repo.git.reset("--hard") + time.sleep(1) + repo.git.cherry_pick(commit.hexsha) + # Rename the module on 18.0 + repo.git.mv(self.addon, self.target_addon) + repo.git.commit("-m", f"Rename {self.addon} to {self.target_addon}") + + def _create_module(self, module_path: Path): + manifest_lines = [ + "# Copyright 2026 Sébastien Alix\n", + "# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)\n", + "{\n", + ' "name": "Test",\n', + ' "version": "1.0.0",\n', + ' "category": "Test Module",\n', + ' "author": "Odoo Community Association (OCA)",\n', + ' "website": "https://github.com/OCA/module-composition-analysis",\n', + ' "license": "AGPL-3",\n', + ' "depends": ["base"],\n', + ' "data": [],\n', + ' "demo": [],\n', + ' "installable": True,\n', + "}\n", + ] + module_path.mkdir(parents=True, exist_ok=True) + manifest_path = module_path / "__manifest__.py" + with open(manifest_path, "w") as manifest: + manifest.writelines(manifest_lines) + + def _add_fork_remote(self, repo_path: Path): + repo = git.Repo(repo_path) + # We do not really care about the remote URL here, re-use origin one + repo.create_remote(self.fork_org, repo.remotes.origin.url) + + def tearDown(self): + super().tearDown() + # Clean up the Git repository + shutil.rmtree(self.repo_upstream_path) + shutil.rmtree(self.repo_path) diff --git a/odoo_repository/tests/test_base_scanner.py b/odoo_repository/tests/test_base_scanner.py index a3d2e51..515e408 100644 --- a/odoo_repository/tests/test_base_scanner.py +++ b/odoo_repository/tests/test_base_scanner.py @@ -13,7 +13,7 @@ def _init_scanner(self, **params): kwargs = { "org": self.fork_org, "name": self.repo_name, - "clone_url": self.repo_upstream_path, + "clone_url": str(self.repo_upstream_path), "branches": [ self.branch1_name, self.branch2_name, diff --git a/odoo_repository/tests/test_odoo_repository_scan.py b/odoo_repository/tests/test_odoo_repository_scan.py index 9298124..12c06ba 100644 --- a/odoo_repository/tests/test_odoo_repository_scan.py +++ b/odoo_repository/tests/test_odoo_repository_scan.py @@ -1,4 +1,5 @@ # Copyright 2024 Camptocamp SA +# Copyright 2026 Sébastien Alix # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from odoo import fields @@ -31,7 +32,7 @@ def test_action_scan_basic(self): self.assertEqual(module_branch.category_id.name, "Test Module") self.assertItemsEqual( module_branch.author_ids.mapped("name"), - ["Odoo Community Association (OCA)", "Camptocamp"], + ["Odoo Community Association (OCA)"], ) self.assertFalse(module_branch.specific) self.assertEqual(module_branch.dependency_ids.module_name, "base") @@ -233,7 +234,7 @@ def test_action_scan_uninstallable_module(self): self.assertEqual(module_branch.category_id.name, "Test Module") self.assertItemsEqual( module_branch.author_ids.mapped("name"), - ["Odoo Community Association (OCA)", "Camptocamp"], + ["Odoo Community Association (OCA)"], ) self.assertFalse(module_branch.specific) # No dependencies diff --git a/odoo_repository/tests/test_repository_scanner.py b/odoo_repository/tests/test_repository_scanner.py index 7102b54..4943e52 100644 --- a/odoo_repository/tests/test_repository_scanner.py +++ b/odoo_repository/tests/test_repository_scanner.py @@ -11,7 +11,7 @@ def _init_scanner(self, **params): kwargs = { "org": self.org.name, "name": self.repo_name, - "clone_url": self.repo_upstream_path, + "clone_url": str(self.repo_upstream_path), "version": self.branch.name, "branch": self.branch.name, "addons_paths_data": [