diff --git a/setup.py b/setup.py index 46f9215b..c7136fa3 100644 --- a/setup.py +++ b/setup.py @@ -27,13 +27,16 @@ "docutils", "pypandoc", # for oca-gen-addon-readme to work with markdown fragments "ERPpeek", + "freezegun", "github3.py>=1", "jinja2", + "manifestoo-core>=1.1", "PyYAML", "polib", "pygments", "requests", "toml>=0.10.0", # for oca-towncrier + "tomli ; python_version < '3.11'", # from 3.11 tomllib is in stdlib "towncrier>=21.3", # for oca-towncrier "selenium", "twine", @@ -64,6 +67,7 @@ "oca-publish-modules = tools.publish_modules:main", "oca-gen-addon-readme = tools.gen_addon_readme:gen_addon_readme", "oca-gen-addon-icon = tools.gen_addon_icon:gen_addon_icon", + "oca-gen-metapackage = tools.gen_metapackage:main", "oca-towncrier = tools.oca_towncrier:oca_towncrier", "oca-create-migration-issue = tools.create_migration_issue:main", "oca-update-pre-commit-excluded-addons = " diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_gen_metapackage.py b/tests/test_gen_metapackage.py new file mode 100644 index 00000000..a911c47e --- /dev/null +++ b/tests/test_gen_metapackage.py @@ -0,0 +1,87 @@ +import textwrap + +from freezegun import freeze_time + +from tools.gen_metapackage import main as gen_metapackage + +from .utils import dir_changer + + +def _make_addon(addons_dir, addon_name, installable=True): + addon_dir = addons_dir / addon_name + addon_dir.mkdir() + manifest = { + "name": addon_name, + "version": "16.0.1.0.0", + "installable": installable, + } + addon_dir.joinpath("__manifest__.py").write_text(repr(manifest)) + addon_dir.joinpath("__init__.py").touch() + + +@freeze_time("2023-05-01") +def test_gen_metapackage(tmp_path): + _make_addon(tmp_path, "addon1") + _make_addon(tmp_path, "addon2") + with dir_changer(tmp_path): + gen_metapackage(["odoo-addons-oca-test-repo"]) + assert ( + tmp_path / "setup" / "_metapackage" / "pyproject.toml" + ).read_text() == textwrap.dedent( + """\ + [project] + name = "odoo-addons-oca-test-repo" + version = "16.0.20230501.0" + dependencies = [ + "odoo-addon-addon1>=16.0dev,<16.1dev", + "odoo-addon-addon2>=16.0dev,<16.1dev", + ] + classifiers=[ + "Programming Language :: Python", + "Framework :: Odoo", + "Framework :: Odoo :: 16.0", + ] + """ + ) + # regenerate with no change + gen_metapackage(["odoo-addons-oca-test-repo"]) + assert ( + tmp_path / "setup" / "_metapackage" / "pyproject.toml" + ).read_text() == textwrap.dedent( + """\ + [project] + name = "odoo-addons-oca-test-repo" + version = "16.0.20230501.0" + dependencies = [ + "odoo-addon-addon1>=16.0dev,<16.1dev", + "odoo-addon-addon2>=16.0dev,<16.1dev", + ] + classifiers=[ + "Programming Language :: Python", + "Framework :: Odoo", + "Framework :: Odoo :: 16.0", + ] + """ + ) + # regenerate with one more addon, test version was incremented + _make_addon(tmp_path, "addon3") + gen_metapackage(["odoo-addons-oca-test-repo"]) + assert ( + tmp_path / "setup" / "_metapackage" / "pyproject.toml" + ).read_text() == textwrap.dedent( + """\ + [project] + name = "odoo-addons-oca-test-repo" + version = "16.0.20230501.1" + dependencies = [ + "odoo-addon-addon1>=16.0dev,<16.1dev", + "odoo-addon-addon2>=16.0dev,<16.1dev", + "odoo-addon-addon3>=16.0dev,<16.1dev", + ] + classifiers=[ + "Programming Language :: Python", + "Framework :: Odoo", + "Framework :: Odoo :: 16.0", + ] + """ + ) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 00000000..de526f62 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,15 @@ +import os +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + + +@contextmanager +def dir_changer(path: Path) -> Iterator[None]: + """A context manager that changes the current working directory""" + old_cwd = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(old_cwd) diff --git a/tools/compat.py b/tools/compat.py new file mode 100644 index 00000000..52969b54 --- /dev/null +++ b/tools/compat.py @@ -0,0 +1,10 @@ +import sys + +__all__ = [ + "tomllib", +] + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib diff --git a/tools/gen_metapackage.py b/tools/gen_metapackage.py new file mode 100644 index 00000000..ff1e43a7 --- /dev/null +++ b/tools/gen_metapackage.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Generate setup/_metapackage with dependencies on all installable addons in repo.""" + +import datetime +import re +import sys +from pathlib import Path +from typing import Any, List, Optional + + +from manifestoo_core.odoo_series import ( + OdooSeries, + detect_from_addon_version as detect_odoo_series_from_addon_version, +) +from manifestoo_core.addon import is_addon_dir, Addon +from manifestoo_core.metadata import addon_name_to_requirement + +from .compat import tomllib + +METAPACKAGE_PATH = Path("setup") / "_metapackage" + +PYPROJECT_TOML_METAPACKAGE = """\ +[project] +name = "{name}" +version = "{version}" +dependencies = {dependencies} +classifiers=[ + "Programming Language :: Python", + "Framework :: Odoo", + "Framework :: Odoo :: {odoo_series}", +] +""" + + +def _gen_metapackage(addons_dir: Path, name: str): + meta_install_requires = [] + odoo_series_detected = set() + metapackage_path = addons_dir / METAPACKAGE_PATH + pyproject_toml_path = metapackage_path / "pyproject.toml" + + for addon_dir in addons_dir.iterdir(): + if not is_addon_dir(addon_dir): + continue + addon = Addon.from_addon_dir(addon_dir) + odoo_series = detect_odoo_series_from_addon_version(addon.manifest.version) + if not odoo_series: + sys.stderr.write( + f"Could not detect Odoo series from addon version in {addon_dir}\n" + ) + return + meta_install_requires.append( + addon_name_to_requirement(addon_dir.name, odoo_series) + ) + odoo_series_detected.add(odoo_series) + + if len(meta_install_requires) == 0: + sys.stderr.write("No installable addon found, not generating metapackage.\n") + return + if len(odoo_series_detected) > 1: + raise RuntimeError( + f"Not all addon are for the same Odoo version: {odoo_series_detected}" + ) + + odoo_series = next(iter(odoo_series_detected)) + + dependencies = "[\n{}]".format( + "".join( + [ + " " * 4 + '"' + install_require + '",\n' + for install_require in sorted(meta_install_requires) + ] + ), + ) + + version = _get_current_version(pyproject_toml_path) + if set(meta_install_requires) != set( + _get_current_dependencies(pyproject_toml_path) + ): + version = _get_next_version(odoo_series, version) + + if not metapackage_path.exists(): + metapackage_path.mkdir(parents=True) + pyproject_toml = PYPROJECT_TOML_METAPACKAGE.format( + name=name, + version=version, + odoo_series=odoo_series.value, + dependencies=dependencies, + ) + pyproject_toml_path.write_text(pyproject_toml) + + _cleanup(metapackage_path) + + +def _get_current_dependencies(pyproject_toml_path: Path) -> Any: + if not pyproject_toml_path.exists(): + return [] + pyproject_toml = tomllib.loads(pyproject_toml_path.read_text()) + return pyproject_toml.get("project", {}).get("dependencies", []) + + +def _get_current_version(pyproject_toml_path: Path) -> Optional[str]: + if not pyproject_toml_path.exists(): + return None + pyproject_toml = tomllib.loads(pyproject_toml_path.read_text()) + return pyproject_toml.get("project", {}).get("version", None) + + +def _get_next_version(odoo_series: OdooSeries, current_version: str): + version_date = datetime.date.today().strftime("%Y%m%d") + if current_version: + version_re = r"^[0-9]{1,2}\.0.(?P[0-9]{8})\.(?P[0-9]+)$" + mo = re.match(version_re, current_version) + if not mo: + raise RuntimeError(f"Could not parse version {current_version}") + if mo.group("date") == version_date: + index = int(mo.group("index")) + 1 + else: + index = 0 + else: + index = 0 + return f"{odoo_series.value}.{version_date}.{index}" + + +def _cleanup(metapackage_path: Path): + for name in ("setup.py", "setup.cfg", "VERSION.txt"): + path = metapackage_path / name + if path.exists(): + path.unlink() + + +def main(argv: Optional[List[str]] = None) -> int: + _gen_metapackage(Path.cwd(), argv[0] if argv else sys.argv[1]) + + +if __name__ == "__main__": + sys.exit(main())