Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions odoo_repository/models/odoo_module_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,21 +294,30 @@ def _compute_dependency_level(self):
(non_std_max_parent_level + 1) if not rec.is_standard else 0
)

def _get_recursive_dependencies(self, domain=None):
def _get_recursive_dependencies(self, domain=None, _visited=None):
"""Return all dependencies recursively.

A domain can be applied to restrict the modules to return, e.g:

>>> mod._get_recursive_dependencies([("org_id", "=", "OCA")])

"""
# NOTE: Circular dependencies are allowed
if not domain:
domain = []
dependencies = self.dependency_ids.filtered_domain(domain)
if _visited is None:
_visited = set()
if self.id in _visited:
return self.browse()
_visited.add(self.id)
# Apply domain and exclude self
dependencies = (self.dependency_ids - self).filtered_domain(domain)
dep_ids = set(dependencies.ids)
for dep in dependencies:
dep_ids |= set(
dep._get_recursive_dependencies().filtered_domain(domain).ids
dep._get_recursive_dependencies(domain, _visited)
.filtered_domain(domain)
.ids
)
return self.browse(dep_ids)

Expand Down
1 change: 1 addition & 0 deletions odoo_repository/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
from . import test_sync_node
from . import test_odoo_module_branch
from . import test_oca_repository_synchronizer
from . import test_odoo_module_branch_recursive_dependencies
77 changes: 41 additions & 36 deletions odoo_repository/tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

import logging
import os
import pathlib
import re
import shutil
import tempfile
from unittest.mock import patch

Expand All @@ -28,36 +28,36 @@ def setUpClass(cls):
cls.env["ir.config_parameter"].set_param(
"odoo_repository_storage_path", cls.repositories_path
)
cls._apply_git_config()
cls._handle_cleanup()

def setUp(self):
super().setUp()
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(
# org and repository
cls.repo_name = cls.repo_upstream_path.parts[-1]
cls.org = cls.env["odoo.repository.org"].create({"name": cls.fork_org})
cls.odoo_repository = cls.env["odoo.repository"].create(
{
"org_id": self.org.id,
"name": self.repo_name,
"repo_url": self.repo_upstream_path,
"clone_url": self.repo_upstream_path,
"org_id": cls.org.id,
"name": cls.repo_name,
"repo_url": cls.repo_upstream_path,
"clone_url": cls.repo_upstream_path,
"repo_type": "github",
}
)
# branch1
self.branch1_name = self.source1.split("/")[1]
self.branch = (
self.env["odoo.branch"]
cls.branch1_name = cls.source1.split("/")[1]
cls.branch = (
cls.env["odoo.branch"]
.with_context(active_test=False)
.search([("name", "=", self.branch1_name)])
.search([("name", "=", cls.branch1_name)])
)
if not self.branch:
self.branch = self.env["odoo.branch"].create(
if not cls.branch:
cls.branch = cls.env["odoo.branch"].create(
{
"name": self.branch1_name,
"name": cls.branch1_name,
}
)
self.branch.active = True
cls.branch.active = True
cls._handle_cleanup()

def setUp(self):
super().setUp()
# branch2
self.branch2_name = self.source2.split("/")[1]
self.branch2 = (
Expand All @@ -78,15 +78,6 @@ def setUp(self):
self.module_name = self.addon
self.module_branch_model = self.env["odoo.module.branch"]

@classmethod
def _apply_git_config(cls):
"""Configure git (~/.gitconfig) if no config file exists."""
git_cfg = pathlib.Path(os.path.expanduser("~/.gitconfig"))
if git_cfg.exists():
return
os.system("git config --global user.email 'test@example.com'")
os.system("git config --global user.name 'test'")

def _patch_github_class(self):
# Patch helper method part of 'odoo_repository' module
self.patcher = patch("odoo.addons.odoo_repository.utils.github.request")
Expand Down Expand Up @@ -145,24 +136,27 @@ def _run_odoo_repository_action_scan(self, branch_id, force=False):
branch_ids=[branch_id], force=force
)

def _create_odoo_module(self, name):
return self.env["odoo.module"].create({"name": name})
@classmethod
def _create_odoo_module(cls, name):
return cls.env["odoo.module"].create({"name": name})

def _create_odoo_repository_branch(self, repo, branch, **values):
@classmethod
def _create_odoo_repository_branch(cls, repo, branch, **values):
vals = {
"repository_id": repo.id,
"branch_id": branch.id,
}
vals.update(values)
return self.env["odoo.repository.branch"].create(vals)
return cls.env["odoo.repository.branch"].create(vals)

def _create_odoo_module_branch(self, module, branch, **values):
@classmethod
def _create_odoo_module_branch(cls, module, branch, **values):
vals = {
"module_id": module.id,
"branch_id": branch.id,
}
vals.update(values)
return self.env["odoo.module.branch"].create(vals)
return cls.env["odoo.module.branch"].create(vals)

@classmethod
def _handle_cleanup(cls):
Expand All @@ -187,3 +181,14 @@ def kill_remaining_git_processes():
psutil.wait_procs(children, timeout=10)

cls.addClassCleanup(kill_remaining_git_processes)

@classmethod
def tearDownClass(cls):
super().tearDownClass()
shutil.rmtree(cls.repositories_path)

def tearDown(self):
super().tearDown()
repositories_path = pathlib.Path(self.repositories_path)
for sub_path in repositories_path.iterdir():
shutil.rmtree(sub_path)
89 changes: 45 additions & 44 deletions odoo_repository/tests/odoo_repo_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

import io
import os
import shutil
import tempfile
import threading
Expand Down Expand Up @@ -29,34 +30,40 @@ def setUpClass(cls):
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:
cls._apply_git_config()
cls.repo_upstream_path = cls._get_upstream_repository_path()
cls.addon_path = Path(cls.repo_upstream_path) / cls.addon
cls.manifest_path = cls.addon_path / "__manifest__.py"

@classmethod
def _apply_git_config(cls):
"""Configure git (~/.gitconfig) if no config file exists."""
git_cfg = Path(os.path.expanduser("~/.gitconfig"))
if git_cfg.exists():
return
os.system("git config --global user.email 'test@example.com'")
os.system("git config --global user.name 'test'")

@classmethod
def _get_upstream_repository_path(cls) -> 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)
repo_path = cls._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)
repo_path = cls._create_tmp_git_repository()
addon_path = repo_path / cls.addon
cls._fill_git_repository(repo_path, addon_path)
cache.archive_data = cls._archive_upstream_repository(repo_path)
return repo_path

def _archive_upstream_repository(self, repo_path: Path) -> bytes:
@classmethod
def _archive_upstream_repository(cls, repo_path: Path) -> bytes:
"""Archive the repository located at `repo_path`.

Returns binary value of the archive.
Expand All @@ -70,7 +77,8 @@ def _archive_upstream_repository(self, repo_path: Path) -> bytes:
zipf.write(file_path, arcname)
return zip_buffer.getvalue()

def _unarchive_upstream_repository(self, archive_data: bytes) -> Path:
@classmethod
def _unarchive_upstream_repository(cls, archive_data: bytes) -> Path:
"""Unarchive the repository contained in `archive_data`.

Returns path of repository.
Expand All @@ -83,50 +91,48 @@ def _unarchive_upstream_repository(self, archive_data: bytes) -> Path:
if path.is_dir() and ".git" in path.name:
return path.parent

def _create_tmp_git_repository(self) -> Path:
@classmethod
def _create_tmp_git_repository(cls) -> 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):
@classmethod
def _fill_git_repository(cls, 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]
branch1 = cls.source1.split("/")[1]
repo.git.checkout("--orphan", branch1)
self._create_module(addon_path)
cls._create_module(addon_path)
repo.index.add(addon_path)
commit = repo.index.commit(f"[ADD] {self.addon}")
commit = repo.index.commit(f"[ADD] {cls.addon}")
# Port the commit from 15.0 to 16.0
branch2 = self.source2.split("/")[1]
branch2 = cls.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]
branch3 = cls.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]
branch4 = cls.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}")
repo.git.mv(cls.addon, cls.target_addon)
repo.git.commit("-m", f"Rename {cls.addon} to {cls.target_addon}")

def _create_module(self, module_path: Path):
@classmethod
def _create_module(cls, module_path: Path):
manifest_lines = [
"# Copyright 2026 Sébastien Alix\n",
"# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)\n",
Expand All @@ -148,13 +154,8 @@ def _create_module(self, module_path: Path):
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)
@classmethod
def tearDownClass(cls):
super().tearDownClass()
# Clean up upstream Git repository
shutil.rmtree(cls.repo_upstream_path)
Loading