From cb9dedcb7cd6b747bf083136e400e9b0aedf6dfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Fri, 26 Sep 2025 09:47:22 +0200 Subject: [PATCH 1/2] [ADD] oca-create-branch-from-previous --- pyproject.toml | 1 + tools/create_branch_from_previous.py | 138 +++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 tools/create_branch_from_previous.py diff --git a/pyproject.toml b/pyproject.toml index e4e9c4899..cb8ec79c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ oca-update-pre-commit-excluded-addons = "tools.update_pre_commit_excluded_addons oca-fix-manifest-website = "tools.fix_manifest_website:main" oca-configure-travis= "tools.configure_travis:main" oca-copier-update = "tools.copier_update:main" +oca-create-branch-from-previous = "tools.create_branch_from_previous:main" [tool.hatch.build.targets.wheel] packages = ["tools"] diff --git a/tools/create_branch_from_previous.py b/tools/create_branch_from_previous.py new file mode 100644 index 000000000..c2da9d929 --- /dev/null +++ b/tools/create_branch_from_previous.py @@ -0,0 +1,138 @@ +import ast +import re +import subprocess +from pathlib import Path + +import click + + +def _mark_modules_uninstallable(addons_dir: Path) -> None: + for manifest_path in addons_dir.glob("*/__manifest__.py"): + manifest_text = manifest_path.read_text(encoding="utf-8") + manifest = ast.literal_eval(manifest_text) + if "installable" not in manifest: + src = r",?\s*}" + dest = ",\n 'installable': False,\n}" + else: + src = "[\"']installable[\"']: *True" + dest = '"installable": False' + manifest_path.write_text(re.sub(src, dest, manifest_text, re.DOTALL)) + + +@click.command() +@click.option("--odoo-version", required=True) +@click.option("--new-branch-name") +@click.option( + "--addons-dir", + type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path), + default=".", +) +@click.option( + "--data", + multiple=True, + help="Additional copier data, as key=value", +) +def main( + odoo_version: str, + new_branch_name: str | None, + addons_dir: Path, + data: list[str], +) -> None: + """Create a new branch off an existing branch and set all addons installable=False. + + To use it, go to a git clone of a repo and checkout the branch you want to start from. + """ + if not new_branch_name: + new_branch_name = odoo_version + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + check=True, + text=True, + capture_output=True, + ) + previous_branch = result.stdout.strip() + subprocess.run( + [ + "git", + "checkout", + "-b", + new_branch_name, + ], + check=True, + ) + result = subprocess.run( + [ + "copier", + "recopy", # override local changes when creating a new branch + "--trust", + "--defaults", + "--overwrite", + f"--data=odoo_version={odoo_version}", + *(f"--data={d}" for d in data), + ], + ) + if result.returncode != 0: + raise SystemExit("copier update failed, please fix manually") + result = subprocess.run( + [ + "git", + "diff", + "--diff-filter=U", + "--quiet", + ], + ) + if result.returncode != 0: + raise SystemExit( + "There are merge conflicts after copier update, please fix manually" + ) + _mark_modules_uninstallable(addons_dir) + if Path(".pre-commit-config.yaml").exists(): + # First run pre-commit on .pre-commit-config.yaml, to exclude + # addons that are not installable. + subprocess.run( + [ + "pre-commit", + "run", + "--files", + ".pre-commit-config.yaml", + ], + check=False, + ) + # Run pre-commit once to let it apply auto fixes. + subprocess.run( + [ + "pre-commit", + "run", + "--all-files", + ], + check=False, + ) + # Run pre-commit a second time to check that everything is green. + result = subprocess.run( + [ + "pre-commit", + "run", + "--all-files", + ], + check=False, + ) + if result.returncode != 0: + raise SystemExit("pre-commit failed, please fix manually") + subprocess.run( + [ + "git", + "add", + ".", + ], + check=True, + ) + subprocess.run( + [ + "git", + "commit", + "-m", + f"[MIG] Create {new_branch_name} from {previous_branch} " + f"for Odoo {odoo_version}", + ], + check=True, + ) From 2e62de4f02e9c0f040e3c9a816ef76093348b1fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Tue, 28 Oct 2025 14:59:46 +0100 Subject: [PATCH 2/2] Fix and test mark_module_uninstallable --- tests/test_mark_modules_uninstallable.py | 50 ++++++++++++++++++++++++ tools/create_branch_from_previous.py | 17 +------- tools/manifest.py | 20 ++++++++++ 3 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 tests/test_mark_modules_uninstallable.py diff --git a/tests/test_mark_modules_uninstallable.py b/tests/test_mark_modules_uninstallable.py new file mode 100644 index 000000000..80650454d --- /dev/null +++ b/tests/test_mark_modules_uninstallable.py @@ -0,0 +1,50 @@ +import textwrap + +from tools.manifest import mark_modules_uninstallable, mark_manifest_uninstallable + + +def test_mark_module_uninstallable(tmp_path): + (tmp_path / "mod1").mkdir() + (tmp_path / "mod1" / "__manifest__.py").write_text("""{'name': 'mod1'}""") + mark_modules_uninstallable(tmp_path) + assert (tmp_path / "mod1" / "__manifest__.py").read_text() == ( + """{'name': 'mod1',\n 'installable': False,\n}\n""" + ) + + +def test_mark_module_uninstallable_key_exists(tmp_path): + (tmp_path / "mod1").mkdir() + (tmp_path / "mod1" / "__manifest__.py").write_text( + """{'name': 'mod1', "installable": True}""" + ) + mark_modules_uninstallable(tmp_path) + assert (tmp_path / "mod1" / "__manifest__.py").read_text() == ( + """{'name': 'mod1', "installable": False}""" + ) + + +def test_mark_module_uninstallable_curly_braces(tmp_path): + assert mark_manifest_uninstallable( + textwrap.dedent( + """\ + { + 'name': 'mod1', + 'external_dependencies': { + 'python': ['some_package'], + }, + 'license': 'AGPL-3', + } + """ + ) + ) == textwrap.dedent( + """\ + { + 'name': 'mod1', + 'external_dependencies': { + 'python': ['some_package'], + }, + 'license': 'AGPL-3', + 'installable': False, + } + """ + ) diff --git a/tools/create_branch_from_previous.py b/tools/create_branch_from_previous.py index c2da9d929..7ecb603a1 100644 --- a/tools/create_branch_from_previous.py +++ b/tools/create_branch_from_previous.py @@ -1,22 +1,9 @@ -import ast -import re import subprocess from pathlib import Path import click - -def _mark_modules_uninstallable(addons_dir: Path) -> None: - for manifest_path in addons_dir.glob("*/__manifest__.py"): - manifest_text = manifest_path.read_text(encoding="utf-8") - manifest = ast.literal_eval(manifest_text) - if "installable" not in manifest: - src = r",?\s*}" - dest = ",\n 'installable': False,\n}" - else: - src = "[\"']installable[\"']: *True" - dest = '"installable": False' - manifest_path.write_text(re.sub(src, dest, manifest_text, re.DOTALL)) +from .manifest import mark_modules_uninstallable @click.command() @@ -85,7 +72,7 @@ def main( raise SystemExit( "There are merge conflicts after copier update, please fix manually" ) - _mark_modules_uninstallable(addons_dir) + mark_modules_uninstallable(addons_dir) if Path(".pre-commit-config.yaml").exists(): # First run pre-commit on .pre-commit-config.yaml, to exclude # addons that are not installable. diff --git a/tools/manifest.py b/tools/manifest.py index daea0f36a..5c1d4f306 100644 --- a/tools/manifest.py +++ b/tools/manifest.py @@ -3,6 +3,8 @@ import ast import os +import re +from pathlib import Path MANIFEST_NAMES = ("__manifest__.py", "__openerp__.py", "__terp__.py") @@ -41,3 +43,21 @@ def find_addons(addons_dir, installable_only=True): if installable_only and not manifest.get("installable", True): continue yield addon_name, addon_dir, manifest + + +def mark_manifest_uninstallable(manifest_text: str) -> str: + manifest = ast.literal_eval(manifest_text) + if "installable" not in manifest: + src = r",?\s*}\s*$" + dest = ",\n 'installable': False,\n}\n" + else: + src = "[\"']installable[\"']: *True" + dest = '"installable": False' + return re.sub(src, dest, manifest_text, re.DOTALL) + + +def mark_modules_uninstallable(addons_dir: Path) -> None: + for manifest_path in addons_dir.glob("*/__manifest__.py"): + manifest_text = manifest_path.read_text(encoding="utf-8") + new_manifest_text = mark_manifest_uninstallable(manifest_text) + manifest_path.write_text(new_manifest_text, encoding="utf-8")