From 8931deaae11ca05309416a027291ccb5d8160185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Mon, 31 Jul 2023 00:20:30 +0200 Subject: [PATCH] Remove setuptools-odoo dependency. Implement metadata_from_addon_dir natively. The test suite for that was already most developed than setuptools-odoo's. --- .coveragerc | 3 + docs/api.md | 8 - pyproject.toml | 6 +- src/manifestoo_core/exceptions.py | 8 + src/manifestoo_core/git_postversion.py | 170 ++++++++++ src/manifestoo_core/manifest.py | 22 +- src/manifestoo_core/metadata.py | 443 ++++++++++++++++++++++--- tests/test_metadata.py | 44 ++- 8 files changed, 643 insertions(+), 61 deletions(-) create mode 100644 .coveragerc create mode 100644 src/manifestoo_core/git_postversion.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..7563da0 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +source = + manifestoo_core diff --git a/docs/api.md b/docs/api.md index 0285463..fa32c79 100644 --- a/docs/api.md +++ b/docs/api.md @@ -24,14 +24,6 @@ ## `manifestoo_core.metadata` -```{note} -For this module to be functional, `manifestoo-core` must currently be installed -with the `metadata` extra (`pip install manifestoo-core[metadata]`) because it pulls -`setuptools-odoo` as a dependency to implement this functionality. In the future, -this feature will be implemented natively as part of `manifestoo-core` and the extra -will become unnecessary. -``` - ```{eval-rst} .. automodule:: manifestoo_core.metadata :members: diff --git a/pyproject.toml b/pyproject.toml index db0abd7..2206b40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,16 +20,16 @@ classifiers = [ ] readme = "README.md" dependencies = [ + "dataclasses ; python_version<'3.7'", "importlib_resources ; python_version<'3.7'", + "packaging", "typing-extensions ; python_version < '3.8'", ] requires-python = ">=3.6" dynamic = ["version"] [project.optional-dependencies] -metadata = [ - "setuptools-odoo>=3.1", -] +metadata = [] test = [ "pytest", "coverage[toml]", diff --git a/src/manifestoo_core/exceptions.py b/src/manifestoo_core/exceptions.py index efd5d1d..3357d97 100644 --- a/src/manifestoo_core/exceptions.py +++ b/src/manifestoo_core/exceptions.py @@ -36,3 +36,11 @@ class AddonNotFoundNotADirectory(AddonNotFound): class AddonNotFoundInvalidManifest(AddonNotFound): pass + + +class InvalidDistributionName(ManifestooException): + pass + + +class UnknownPostVersionStrategy(ManifestooException): + pass diff --git a/src/manifestoo_core/git_postversion.py b/src/manifestoo_core/git_postversion.py new file mode 100644 index 0000000..a5729e8 --- /dev/null +++ b/src/manifestoo_core/git_postversion.py @@ -0,0 +1,170 @@ +import os +import subprocess +import sys +from pathlib import Path +from typing import Iterator, List, Optional, TextIO + +from packaging.version import parse as parse_version + +if sys.version_info >= (3, 8): + from typing import Final +else: + from typing_extensions import Final + +from .addon import Addon +from .exceptions import InvalidManifest, UnknownPostVersionStrategy +from .manifest import MANIFEST_NAMES, Manifest + +POST_VERSION_STRATEGY_NONE: Final = "none" +POST_VERSION_STRATEGY_NINETYNINE_DEVN: Final = ".99.devN" +POST_VERSION_STRATEGY_P1_DEVN: Final = "+1.devN" +POST_VERSION_STRATEGY_DOT_N: Final = ".N" + + +def _run_git_command_exit_code( + args: List[str], cwd: Optional[Path] = None, stderr: Optional[TextIO] = None +) -> int: + return subprocess.call(["git", *args], cwd=cwd, stderr=stderr) + + +def _run_git_command_bytes( + args: List[str], cwd: Optional[Path] = None, stderr: Optional[TextIO] = None +) -> str: + output = subprocess.check_output( + ["git", *args], cwd=cwd, universal_newlines=True, stderr=stderr + ) + return output.strip() + + +def _run_git_command_lines( + args: List[str], cwd: Optional[Path] = None, stderr: Optional[TextIO] = None +) -> List[str]: + output = _run_git_command_bytes(args, cwd=cwd, stderr=stderr) + return output.split("\n") + + +def _is_git_controlled(path: Path) -> bool: + with open(os.devnull, "w") as devnull: + r = _run_git_command_exit_code(["rev-parse"], cwd=path, stderr=devnull) + return r == 0 + + +def _get_git_uncommitted(path: Path) -> bool: + r = _run_git_command_exit_code(["diff", "--quiet", "--exit-code", "."], cwd=path) + return r != 0 + + +def _get_git_root(path: Path) -> Path: + return Path(_run_git_command_bytes(["rev-parse", "--show-toplevel"], cwd=path)) + + +def _git_log_iterator(path: Path) -> Iterator[str]: + """yield commits using git log -- """ + n = 10 + count = 0 + while True: + lines = _run_git_command_lines( + ["log", "--oneline", "-n", str(n), "--skip", str(count), "--", "."], + cwd=path, + ) + for line in lines: + sha = line.split(" ", 1)[0] + count += 1 + yield sha + if len(lines) < n: + break + + +def _read_manifest_from_sha( + sha: str, addon_dir: Path, git_root: Path +) -> Optional[Manifest]: + rel_addon_dir = addon_dir.relative_to(git_root) + for manifest_name in MANIFEST_NAMES: + manifest_path = rel_addon_dir / manifest_name + try: + with Path(os.devnull).open("w") as devnull: + s = _run_git_command_bytes( + ["show", f"{sha}:{manifest_path}"], cwd=git_root, stderr=devnull + ) + except subprocess.CalledProcessError: + continue + try: + return Manifest.from_str(s) + except InvalidManifest: + break + return None + + +def _bump_last(version: str) -> str: + int_version = [int(i) for i in version.split(".")] + int_version[-1] += 1 + return ".".join(str(i) for i in int_version) + + +def get_git_postversion(addon: Addon, strategy: str) -> str: # noqa: C901 too complex + """return the addon version number, with a developmental version increment + if there were git commits in the addon_dir after the last version change. + + If the last change to the addon correspond to the version number in the + manifest it is used as is for the python package version. Otherwise a + counter is incremented for each commit and resulting version number has + the following form, depending on the strategy (N being the number of git + commits since the version change): + + * STRATEGY_NONE: return the version in the manifest as is + * STRATEGY_99_DEVN: [8|9].0.x.y.z.99.devN + * STRATEGY_P1_DEVN: [series].0.x.y.(z+1).devN + * STRATEGY_DOT_N: [series].0.x.y.z.N + + Notes: + + * pip ignores .postN by design (https://github.com/pypa/pip/issues/2872) + * x.y.z.devN is anterior to x.y.z + + Note: we don't put the sha1 of the commit in the version number because + this is not PEP 440 compliant and is therefore misinterpreted by pip. + """ + last_version = addon.manifest.version or "0.0.0" + addon_dir = addon.path.resolve() + if strategy == POST_VERSION_STRATEGY_NONE: + return last_version + last_version_parsed = parse_version(last_version) + if not _is_git_controlled(addon_dir): + return last_version + if _get_git_uncommitted(addon_dir): + uncommitted = True + count = 1 + else: + uncommitted = False + count = 0 + last_sha = None + git_root = _get_git_root(addon_dir) + for sha in _git_log_iterator(addon_dir): + manifest = _read_manifest_from_sha(sha, addon_dir, git_root) + if manifest is None: + break + version = manifest.version or "0.0.0" + version_parsed = parse_version(version) + if version_parsed != last_version_parsed: + break + if last_sha is None: + last_sha = sha + else: + count += 1 + if not count: + return last_version + if last_sha: + if strategy == POST_VERSION_STRATEGY_NINETYNINE_DEVN: + return last_version + ".99.dev%s" % count + if strategy == POST_VERSION_STRATEGY_P1_DEVN: + return _bump_last(last_version) + ".dev%s" % count + if strategy == POST_VERSION_STRATEGY_DOT_N: + return last_version + ".%s" % count + msg = f"Unknown postversion strategy: {strategy}" + raise UnknownPostVersionStrategy(msg) + if uncommitted: + return last_version + ".dev1" + # if everything is committed, the last commit + # must have the same version as current, + # so last_sha must be set and we'll never reach this branch + return last_version diff --git a/src/manifestoo_core/manifest.py b/src/manifestoo_core/manifest.py index 6ed6923..6f13ee9 100644 --- a/src/manifestoo_core/manifest.py +++ b/src/manifestoo_core/manifest.py @@ -7,7 +7,9 @@ T = TypeVar("T") VT = TypeVar("VT") -__all__ = ["Manifest", "InvalidManifest", "get_manifest_path"] +__all__ = ["Manifest", "InvalidManifest", "get_manifest_path", "MANIFEST_NAMES"] + +MANIFEST_NAMES = ("__manifest__.py", "__openerp__.py", "__terp__.py") def _check_str(value: Any) -> str: @@ -61,7 +63,7 @@ def get_manifest_path(addon_dir: Path) -> Optional[Path]: Returns None if no manifest file is found. """ - for manifest_name in ("__manifest__.py", "__openerp__.py", "__terp__.py"): + for manifest_name in MANIFEST_NAMES: manifest_path = addon_dir / manifest_name if manifest_path.is_file(): return manifest_path @@ -88,6 +90,14 @@ def _get(self, key: str, checker: Callable[[Any], T], default: T) -> T: def name(self) -> Optional[str]: return self._get("name", _check_optional_str, default=None) + @property + def summary(self) -> Optional[str]: + return self._get("summary", _check_optional_str, default=None) + + @property + def description(self) -> Optional[str]: + return self._get("description", _check_optional_str, default=None) + @property def version(self) -> Optional[str]: return self._get("version", _check_optional_str, default=None) @@ -110,6 +120,14 @@ def external_dependencies(self) -> Dict[str, List[str]]: def license(self) -> Optional[str]: # noqa: A003 return self._get("license", _check_optional_str, default=None) + @property + def author(self) -> Optional[str]: + return self._get("author", _check_optional_str, default=None) + + @property + def website(self) -> Optional[str]: + return self._get("website", _check_optional_str, default=None) + @property def development_status(self) -> Optional[str]: return self._get("development_status", _check_optional_str, default=None) diff --git a/src/manifestoo_core/metadata.py b/src/manifestoo_core/metadata.py index 378e492..11e6969 100644 --- a/src/manifestoo_core/metadata.py +++ b/src/manifestoo_core/metadata.py @@ -1,26 +1,50 @@ +import email.parser import re import sys +import warnings +from dataclasses import dataclass from email.message import Message from pathlib import Path -from typing import Dict, Iterator, List, Optional, Sequence, Union +from typing import Dict, Iterator, List, Optional, Sequence, Set, Tuple, Union if sys.version_info >= (3, 8): - from typing import Final, TypedDict + from typing import TypedDict else: - from typing_extensions import Final, TypedDict + from typing_extensions import TypedDict from .addon import Addon -from .exceptions import UnsupportedManifestVersion, UnsupportedOdooVersion - -POST_VERSION_STRATEGY_NONE: Final = "none" -POST_VERSION_STRATEGY_NINETYNINE_DEVN: Final = ".99.devN" -POST_VERSION_STRATEGY_P1_DEVN: Final = "+1.devN" -POST_VERSION_STRATEGY_DOT_N: Final = ".N" +from .core_addons import get_core_addons +from .exceptions import ( + InvalidDistributionName, + UnsupportedManifestVersion, + UnsupportedOdooVersion, +) +from .git_postversion import ( + POST_VERSION_STRATEGY_DOT_N, + POST_VERSION_STRATEGY_NINETYNINE_DEVN, + POST_VERSION_STRATEGY_NONE, + POST_VERSION_STRATEGY_P1_DEVN, + get_git_postversion, +) +from .manifest import Manifest +from .odoo_series import OdooSeries ODOO_ADDON_DIST_RE = re.compile( r"^(odoo(\d{1,2})?[-_]addon[-_].*|odoo$|odoo[^a-zA-Z0-9._-]+)", re.IGNORECASE, ) +ODOO_ADDON_METADATA_NAME_RE = re.compile( + r"^odoo(\d{1,2})?[-_]addon[-_](?P[a-z0-9_-]+)$", +) + + +__all__ = [ + "POST_VERSION_STRATEGY_DOT_N", + "POST_VERSION_STRATEGY_NINETYNINE_DEVN", + "POST_VERSION_STRATEGY_NONE", + "POST_VERSION_STRATEGY_P1_DEVN", + "metadata_from_addon_dir", +] def _filter_odoo_addon_dependencies(dependencies: Sequence[str]) -> Iterator[str]: @@ -60,7 +84,7 @@ def metadata_from_addon_dir( options: Optional[MetadataOptions] = None, precomputed_metadata_file: Optional[Path] = None, ) -> Message: - """Return Python Package Metadata 2.2 for an Odoo addon directory as an + """Return Python Package Metadata 2.1 for an Odoo addon directory as an ``email.message.Message``. The Description field is absent and is stored in the message payload. All values are @@ -76,48 +100,373 @@ def metadata_from_addon_dir( ``addon_dir`` does not contain a valid installable Odoo addon for a supported Odoo version. """ - Addon.from_addon_dir(addon_dir) # check we have a proper addon directory - return _metadata_from_addon_dir_using_setuptools_odoo( - addon_dir, - options or MetadataOptions(), - precomputed_metadata_file, - ) - - -def _metadata_from_addon_dir_using_setuptools_odoo( - addon_dir: Path, - options: MetadataOptions, - precomputed_metadata_file: Optional[Path] = None, -) -> Message: - from distutils.errors import DistutilsSetupError - - from setuptools_odoo import get_addon_metadata # type: ignore[import] + if options is None: + options = MetadataOptions() + addon = Addon.from_addon_dir(addon_dir) + manifest = addon.manifest - try: - metadata = get_addon_metadata( - str(addon_dir), - depends_override=options.get("depends_override"), - external_dependencies_override=options.get( - "external_dependencies_override" - ), - odoo_version_override=options.get("odoo_version_override"), + if precomputed_metadata_file and precomputed_metadata_file.is_file(): + with precomputed_metadata_file.open(encoding="utf-8") as fp: + pkg_info = email.parser.HeaderParser().parse(fp) + addon_name = _addon_name_from_metadata_name(pkg_info["Name"]) + version = pkg_info["Version"] + _, odoo_series, odoo_version_info = _get_version( + addon, + options.get("odoo_version_override"), + git_post_version=False, + ) + else: + addon_name = addon_dir.absolute().name + version, odoo_series, odoo_version_info = _get_version( + addon, + options.get("odoo_version_override"), + git_post_version=True, post_version_strategy_override=options.get( "post_version_strategy_override" ), - precomputed_metadata_path=precomputed_metadata_file, ) - except DistutilsSetupError as e: - if "Unsupported odoo version" in str(e): - raise UnsupportedOdooVersion(str(e)) from e - if "Version in manifest must" in str(e): - raise UnsupportedManifestVersion(str(e)) from e - raise - + install_requires = _get_install_requires( + odoo_version_info, + manifest, + depends_override=options.get("depends_override"), + external_dependencies_override=options.get("external_dependencies_override"), + ) # Update Requires-Dist metadata with dependencies that are not odoo nor odoo addons if options.get("external_dependencies_only"): - requires_dist = metadata.get_all("Requires-Dist") - del metadata["Requires-Dist"] - for requires_dist_entry in _filter_odoo_addon_dependencies(requires_dist): - metadata["Requires-Dist"] = requires_dist_entry + install_requires = list(_filter_odoo_addon_dependencies(install_requires)) + + def _set(key: str, value: Union[None, str, List[str]]) -> None: + if not value: + return + if isinstance(value, list): + for v in value: + meta[key] = v + else: + meta[key] = value + + meta = Message() + _set("Metadata-Version", "2.1") + _set("Name", _addon_name_to_metadata_name(odoo_version_info, addon_name)) + _set("Version", version) + _set("Requires-Python", odoo_version_info.python_requires) + _set("Requires-Dist", install_requires) + _set("Summary", _no_nl(manifest.summary or manifest.name)) + _set("Home-page", manifest.website) + _set("License", manifest.license) + _set("Author", _no_nl(manifest.author)) + _set("Author-email", _author_email(manifest.author)) + _set("Classifier", _make_classifiers(odoo_series, manifest)) + long_description = _long_description(addon) + if long_description: + meta.set_payload(long_description) + + return meta + + +@dataclass +class OdooVersionInfo: + odoo_dep: str + pkg_name_pfx: str + python_requires: str + universal_wheel: bool + git_postversion_strategy: str + core_addons: Set[str] + pkg_version_specifier: str = "" + addons_ns: Optional[str] = None + namespace_packages: Optional[List[str]] = None + + +ODOO_VERSION_INFO = { + "8.0": OdooVersionInfo( + odoo_dep="odoo>=8.0a,<9.0a", + pkg_name_pfx="odoo8-addon", + addons_ns="odoo_addons", + namespace_packages=["odoo_addons"], + python_requires="~=2.7", + universal_wheel=False, + git_postversion_strategy=POST_VERSION_STRATEGY_NINETYNINE_DEVN, + core_addons=get_core_addons(OdooSeries.v8_0), + ), + "9.0": OdooVersionInfo( + odoo_dep="odoo>=9.0a,<9.1a", + pkg_name_pfx="odoo9-addon", + addons_ns="odoo_addons", + namespace_packages=["odoo_addons"], + python_requires="~=2.7", + universal_wheel=False, + git_postversion_strategy=POST_VERSION_STRATEGY_NINETYNINE_DEVN, + core_addons=get_core_addons(OdooSeries.v9_0), + ), + "10.0": OdooVersionInfo( + odoo_dep="odoo>=10.0,<10.1dev", + pkg_name_pfx="odoo10-addon", + addons_ns="odoo.addons", + namespace_packages=["odoo", "odoo.addons"], + python_requires="~=2.7", + universal_wheel=False, + git_postversion_strategy=POST_VERSION_STRATEGY_NINETYNINE_DEVN, + core_addons=get_core_addons(OdooSeries.v10_0), + ), + "11.0": OdooVersionInfo( + odoo_dep="odoo>=11.0a,<11.1dev", + pkg_name_pfx="odoo11-addon", + addons_ns="odoo.addons", + namespace_packages=None, + python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", + universal_wheel=True, + git_postversion_strategy=POST_VERSION_STRATEGY_NINETYNINE_DEVN, + core_addons=get_core_addons(OdooSeries.v11_0), + ), + "12.0": OdooVersionInfo( + odoo_dep="odoo>=12.0a,<12.1dev", + pkg_name_pfx="odoo12-addon", + addons_ns="odoo.addons", + namespace_packages=None, + python_requires=">=3.5", + universal_wheel=False, + git_postversion_strategy=POST_VERSION_STRATEGY_NINETYNINE_DEVN, + core_addons=get_core_addons(OdooSeries.v12_0), + ), + "13.0": OdooVersionInfo( + odoo_dep="odoo>=13.0a,<13.1dev", + pkg_name_pfx="odoo13-addon", + addons_ns="odoo.addons", + namespace_packages=None, + python_requires=">=3.5", + universal_wheel=False, + git_postversion_strategy=POST_VERSION_STRATEGY_P1_DEVN, + core_addons=get_core_addons(OdooSeries.v13_0), + ), + "14.0": OdooVersionInfo( + odoo_dep="odoo>=14.0a,<14.1dev", + pkg_name_pfx="odoo14-addon", + addons_ns="odoo.addons", + namespace_packages=None, + python_requires=">=3.6", + universal_wheel=False, + git_postversion_strategy=POST_VERSION_STRATEGY_P1_DEVN, + core_addons=get_core_addons(OdooSeries.v14_0), + ), + "15.0": OdooVersionInfo( + odoo_dep="odoo>=15.0a,<15.1dev", + pkg_name_pfx="odoo-addon", + pkg_version_specifier=">=15.0dev,<15.1dev", + addons_ns="odoo.addons", + namespace_packages=None, + python_requires=">=3.8", + universal_wheel=False, + git_postversion_strategy=POST_VERSION_STRATEGY_DOT_N, + core_addons=get_core_addons(OdooSeries.v15_0), + ), + "16.0": OdooVersionInfo( + odoo_dep="odoo>=16.0a,<16.1dev", + pkg_name_pfx="odoo-addon", + pkg_version_specifier=">=16.0dev,<16.1dev", + addons_ns="odoo.addons", + namespace_packages=None, + python_requires=">=3.10", + universal_wheel=False, + git_postversion_strategy=POST_VERSION_STRATEGY_DOT_N, + core_addons=get_core_addons(OdooSeries.v16_0), + ), +} + +# map names of common python external dependencies in Odoo manifest files +# to actual python package names +EXTERNAL_DEPENDENCIES_MAP = { + "Asterisk": "py-Asterisk", + "coda": "pycoda", + "cups": "pycups", + "dateutil": "python-dateutil", + "ldap": "python-ldap", + "serial": "pyserial", + "suds": "suds-jurko", + "stdnum": "python-stdnum", + "Crypto.Cipher.DES3": "pycrypto", + "OpenSSL": "pyOpenSSL", +} + - return metadata # type: ignore[no-any-return] +def _addon_name_from_metadata_name(metadata_name: str) -> str: + mo = ODOO_ADDON_METADATA_NAME_RE.match(metadata_name) + if not mo: + msg = f"{metadata_name} does not look like an Odoo addon package name" + raise InvalidDistributionName(msg) + return mo.group("addon_name").replace("-", "_") + + +def _addon_name_to_metadata_name( + odoo_version_info: OdooVersionInfo, addon_name: str +) -> str: + return odoo_version_info.pkg_name_pfx + "-" + addon_name + + +def _addon_name_to_requires_dist( + odoo_version_info: OdooVersionInfo, addon_name: str +) -> str: + pkg_name = _addon_name_to_metadata_name(odoo_version_info, addon_name) + pkg_version_specifier = odoo_version_info.pkg_version_specifier + return pkg_name + pkg_version_specifier + + +def _author_email(author: Optional[str]) -> Optional[str]: + if author and "Odoo Community Association (OCA)" in author: + return "support@odoo-community.org" + return None + + +def _no_nl(s: Optional[str]) -> Optional[str]: + if not s: + return s + return " ".join(s.split()) + + +def _long_description(addon: Addon) -> Optional[str]: + readme_path = addon.path / "README.rst" + if readme_path.is_file(): + return readme_path.read_text(encoding="utf-8") + return addon.manifest.description + + +def _make_classifiers(odoo_series: str, manifest: Manifest) -> List[str]: + classifiers = [ + "Programming Language :: Python", + "Framework :: Odoo", + f"Framework :: Odoo :: {odoo_series}", + ] + + # commonly used licenses in OCA + licenses = { + "agpl-3": "License :: OSI Approved :: GNU Affero General Public License v3", + "agpl-3 or any later version": ( + "License :: OSI Approved :: " + "GNU Affero General Public License v3 or later (AGPLv3+)" + ), + "gpl-2": "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", + "gpl-2 or any later version": ( + "License :: OSI Approved :: " + "GNU General Public License v2 or later (GPLv2+)" + ), + "gpl-3": "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "gpl-3 or any later version": ( + "License :: OSI Approved :: " + "GNU General Public License v3 or later (GPLv3+)" + ), + "lgpl-2": ( + "License :: OSI Approved :: " + "GNU Lesser General Public License v2 (LGPLv2)" + ), + "lgpl-2 or any later version": ( + "License :: OSI Approved :: " + "GNU Lesser General Public License v2 or later (LGPLv2+)" + ), + "lgpl-3": ( + "License :: OSI Approved :: " + "GNU Lesser General Public License v3 (LGPLv3)" + ), + "lgpl-3 or any later version": ( + "License :: OSI Approved :: " + "GNU Lesser General Public License v3 or later (LGPLv3+)" + ), + } + license = manifest.license # noqa: A001 `license` is shadowing a python builtin + if license: + license_classifier = licenses.get(license.lower()) + if license_classifier: + classifiers.append(license_classifier) + + # commonly used development status in OCA + development_statuses = { + "alpha": "Development Status :: 3 - Alpha", + "beta": "Development Status :: 4 - Beta", + "production/stable": "Development Status :: 5 - Production/Stable", + "stable": "Development Status :: 5 - Production/Stable", + "production": "Development Status :: 5 - Production/Stable", + "mature": "Development Status :: 6 - Mature", + } + development_status = manifest.development_status + if development_status: + development_status_classifer = development_statuses.get( + development_status.lower() + ) + if development_status_classifer: + classifiers.append(development_status_classifer) + + return classifiers + + +def _get_install_requires( + odoo_version_info: OdooVersionInfo, + manifest: Manifest, + no_depends: Optional[Set[str]] = None, + depends_override: Optional[Dict[str, str]] = None, + external_dependencies_override: Optional[ + Dict[str, Dict[str, Union[str, List[str]]]] + ] = None, +) -> List[str]: + install_requires = [] + # dependency on Odoo + install_requires.append(odoo_version_info.odoo_dep) + # dependencies on other addons (except Odoo official addons) + for depend in manifest.depends: + if depend in odoo_version_info.core_addons: + continue + if no_depends and depend in no_depends: + continue + if depends_override and depend in depends_override: + install_require = depends_override[depend] + else: + install_require = _addon_name_to_requires_dist(odoo_version_info, depend) + if install_require: + install_requires.append(install_require) + # python external_dependencies + for dep in manifest.external_dependencies.get("python", []): + if ( + external_dependencies_override + and dep in external_dependencies_override.get("python", {}) + ): + final_dep = external_dependencies_override.get("python", {})[dep] + else: + final_dep = EXTERNAL_DEPENDENCIES_MAP.get(dep, dep) + if isinstance(final_dep, list): + install_requires.extend(final_dep) + else: + install_requires.append(final_dep) + return sorted(install_requires) + + +def _get_version( + addon: Addon, + odoo_version_override: Optional[str] = None, + git_post_version: bool = True, + post_version_strategy_override: Optional[str] = None, +) -> Tuple[str, str, OdooVersionInfo]: + """Get addon version information from an addon directory""" + version = addon.manifest.version + if not version: + msg = f"No version in manifest in {addon.path}" + warnings.warn(msg, stacklevel=1) + version = "0.0.0" + if not odoo_version_override: + version_parts = version.split(".") + if len(version_parts) < 5: + msg = ( + f"Version in manifest must have at least " + f"5 components and start with " + f"the Odoo series number (in {addon.path})" + ) + raise UnsupportedManifestVersion(msg) + odoo_version = ".".join(version_parts[:2]) + else: + odoo_version = odoo_version_override + if odoo_version not in ODOO_VERSION_INFO: + msg = f"Unsupported odoo version '{odoo_version}' in {addon.path}" + raise UnsupportedOdooVersion(msg) + odoo_version_info = ODOO_VERSION_INFO[odoo_version] + if git_post_version: + version = get_git_postversion( + addon, + post_version_strategy_override + or odoo_version_info.git_postversion_strategy, + ) + return version, odoo_version, odoo_version_info diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 3956ba5..f085e8d 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -6,6 +6,7 @@ from pkg_metadata import msg_to_json from manifestoo_core.exceptions import ( + InvalidDistributionName, UnsupportedManifestVersion, UnsupportedOdooVersion, ) @@ -14,7 +15,10 @@ POST_VERSION_STRATEGY_NINETYNINE_DEVN, POST_VERSION_STRATEGY_NONE, POST_VERSION_STRATEGY_P1_DEVN, + _addon_name_from_metadata_name, + _author_email, _filter_odoo_addon_dependencies, + _no_nl, metadata_from_addon_dir, ) @@ -101,7 +105,7 @@ def test_basic(tmp_path: Path) -> None: "Framework :: Odoo", "Framework :: Odoo :: 14.0", ], - metadata_version="2.2", + metadata_version="2.1", ) @@ -683,3 +687,41 @@ def test_filter_odoo_addon_dependencies( dependencies: List[str], expected: List[str] ) -> None: assert list(_filter_odoo_addon_dependencies(dependencies)) == expected + + +def test_addon_name_from_metadata_name() -> None: + assert _addon_name_from_metadata_name("odoo14-addon-addon1") == "addon1" + assert _addon_name_from_metadata_name("odoo-addon-addon1") == "addon1" + assert _addon_name_from_metadata_name("odoo-addon-addon-1") == "addon_1" + assert _addon_name_from_metadata_name("odoo-addon-addon_1") == "addon_1" + with pytest.raises(InvalidDistributionName): + _addon_name_from_metadata_name("odoo14-addon-") + with pytest.raises(InvalidDistributionName): + _addon_name_from_metadata_name("addon1") + + +def test_get_author_email() -> None: + assert ( + _author_email("Odoo Community Association (OCA)") + == "support@odoo-community.org" + ) + assert ( + _author_email("Odoo Community Association (OCA), ACSONE SA/NV") + == "support@odoo-community.org" + ) + assert _author_email("ACSONE SA/NV") is None + + +@pytest.mark.parametrize( + ("s", "expected"), + [ + ("", ""), + (None, None), + (" ", ""), + (" \n ", ""), + ("a", "a"), + (" a\nb\n", "a b"), + ], +) +def test_no_nl(s: Optional[str], expected: Optional[str]) -> None: + assert _no_nl(s) == expected