From 7963666c5ff600cad13fb38f521dcc9c40072ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Andrei?= Date: Mon, 15 May 2023 20:43:24 +0200 Subject: [PATCH] WIP: Implement plone.api.addons module --- docs/api/addon.md | 17 ++ news/505.feature | 1 + src/plone/api/__init__.py | 1 + src/plone/api/addon.py | 283 ++++++++++++++++++++++++++++++ src/plone/api/tests/test_addon.py | 109 ++++++++++++ 5 files changed, 411 insertions(+) create mode 100644 docs/api/addon.md create mode 100644 news/505.feature create mode 100644 src/plone/api/addon.py create mode 100644 src/plone/api/tests/test_addon.py diff --git a/docs/api/addon.md b/docs/api/addon.md new file mode 100644 index 00000000..0a0d3755 --- /dev/null +++ b/docs/api/addon.md @@ -0,0 +1,17 @@ +--- +myst: + html_meta: + "description": "API methods of module 'addon'" + "property=og:description": "API methods of module 'addon'" + "property=og:title": "plone.api.addon" + "keywords": "Plone, addon, development, API" +--- + +(plone-api-addon)= + +# `plone.api.addon` + +```{eval-rst} +.. automodule:: plone.api.addon + :members: +``` diff --git a/news/505.feature b/news/505.feature new file mode 100644 index 00000000..cdfdbba7 --- /dev/null +++ b/news/505.feature @@ -0,0 +1 @@ +Implement plone.api.addons module [@ericof] diff --git a/src/plone/api/__init__.py b/src/plone/api/__init__.py index fc65182b..7bea6699 100644 --- a/src/plone/api/__init__.py +++ b/src/plone/api/__init__.py @@ -1,5 +1,6 @@ # flake8: NOQA: S401 +from plone.api import addon from plone.api import content from plone.api import env from plone.api import group diff --git a/src/plone/api/addon.py b/src/plone/api/addon.py new file mode 100644 index 00000000..60283862 --- /dev/null +++ b/src/plone/api/addon.py @@ -0,0 +1,283 @@ +"""API to handle addon management.""" +from dataclasses import dataclass +from functools import lru_cache +from plone.api.exc import InvalidParameterError +from plone.api import portal +from plone.api.validation import required_parameters +from Products.CMFPlone.controlpanel.browser.quickinstaller import InstallerView +from Products.CMFPlone.interfaces import INonInstallable +from Products.CMFPlone.utils import get_installer +from Products.GenericSetup import EXTENSION +from typing import Dict +from typing import List +from typing import Tuple +from zope.component import getAllUtilitiesRegisteredFor +from zope.globalrequest import getRequest + +import logging +import pkg_resources + + +logger = logging.getLogger("plone.api.addon") + + +__all__ = [ + "AddonInformation", + "NonInstallableAddons", + "get_addons", + "get_addons_ids", + "get_version", + "get", + "install", + "uninstall" +] + + +@dataclass +class NonInstallableAddons: + """Set of addons not available for installation.""" + + profiles: List[str] + products: List[str] + + +@dataclass +class AddonInformation: + """Addon information.""" + + id: str # noQA + version: str + title: str + description: str + upgrade_profiles: Dict + other_profiles: List[Dict] + install_profile: Dict + uninstall_profile: Dict + profile_type: str + upgrade_info: Dict + valid: bool + flags: List[str] + + def __repr__(self) -> str: + """Return a string representation of this object.""" + return f"" + + +def _get_installer() -> InstallerView: + """Return the InstallerView.""" + portal_obj = portal.get() + return get_installer(portal_obj, getRequest()) + + +@lru_cache(maxsize=1) +def _get_non_installable_addons() -> NonInstallableAddons: + """Return information about non installable addons. + + We cache this on first use, as those utilities are registered + during the application startup + + :returns: NonInstallableAddons instance. + """ + ignore_profiles = [] + ignore_products = [] + utils = getAllUtilitiesRegisteredFor(INonInstallable) + for util in utils: + ni_profiles = getattr(util, "getNonInstallableProfiles", None) + if ni_profiles is not None: + ignore_profiles.extend(ni_profiles()) + ni_products = getattr(util, "getNonInstallableProducts", None) + if ni_products is not None: + ignore_products.extend(ni_products()) + return NonInstallableAddons( + profiles=ignore_profiles, + products=ignore_products, + ) + + +@lru_cache(maxsize=1) +def _cached_addons() -> Tuple[Tuple[str, AddonInformation]]: + """Return information about addons in this installation. + + :returns: Tuple of tuples with addon id and AddonInformation. + """ + installer = _get_installer() + setup_tool = installer.ps + addons = {} + non_installable = _get_non_installable_addons() + # Known profiles: + profiles = setup_tool.listProfileInfo() + + for profile in profiles: + if profile["type"] != EXTENSION: + continue + + pid = profile["id"] + if pid in non_installable.profiles: + continue + pid_parts = pid.split(":") + if len(pid_parts) != 2: + logger.error(f"Profile with id '{pid}' is invalid.") + # Which package (product) is this from? + product_id = profile["product"] + flags = [] + is_broken = not installer.is_product_installable(product_id, allow_hidden=True) + is_non_installable = product_id in non_installable.products + valid = not (is_broken or is_non_installable) + if is_broken: + flags.append("broken") + if is_non_installable: + flags.append("non_installable") + profile_type = pid_parts[-1] + if product_id not in addons: + # get some basic information on the product + product = { + "id": product_id, + "version": get_version(product_id), + "title": product_id, + "description": "", + "upgrade_profiles": {}, + "other_profiles": [], + "install_profile": {}, + "uninstall_profile": {}, + "upgrade_info": {}, + "profile_type": profile_type, + "valid": valid, + "flags": flags, + } + install_profile = installer.get_install_profile(product_id) + if install_profile is not None: + product["title"] = install_profile["title"] + product["description"] = install_profile["description"] + product["install_profile"] = install_profile + product["profile_type"] = "default" + uninstall_profile = installer.get_uninstall_profile(product_id) + if uninstall_profile is not None: + product["uninstall_profile"] = uninstall_profile + # Do not override profile_type. + if not product["profile_type"]: + product["profile_type"] = "uninstall" + if "version" in profile: + product["upgrade_profiles"][profile["version"]] = profile + else: + product["other_profiles"].append(profile) + addons[product_id] = AddonInformation(**product) + return tuple(addons.items()) + + +def _update_addon_info( + installer: InstallerView, addon: AddonInformation +) -> AddonInformation: + """Update information about an addon.""" + addon_id = addon.id + if addon.valid: + flags = [] + # Update only what could be changed + is_installed = installer.is_product_installed(addon_id) + if is_installed: + addon.upgrade_info = installer.upgrade_info(addon_id) or {} + if addon.upgrade_info.get("available"): + flags.append("upgradable") + else: + flags.append("installed") + else: + flags.append("available") + addon.flags = flags + return addon + + +def _get_addons() -> List[AddonInformation]: + """Return an updated list of addon information. + + :returns: List of AddonInformation. + """ + installer = _get_installer() + addons = dict(_cached_addons()) + result = [] + for addon in addons.values(): + result.append(_update_addon_info(installer, addon)) + return result + + +def get_addons(limit: str = "") -> List[AddonInformation]: + """List addons in this Plone site. + + :param limit: Limit list of addons. + 'installed': only products that are installed and not hidden + 'upgradable': only products with upgrades + 'available': products that are not installed but could be + 'non_installable': Non installable products + 'broken': uninstallable products with broken dependencies + :returns: List of AddonInformation. + """ + addons = _get_addons() + if limit in ("non_installable", "broken"): + return [addon for addon in addons if limit in addon.flags] + + addons = [addon for addon in addons if addon.valid] + if limit in ("installed", "upgradable", "available"): + addons = [addon for addon in addons if limit in addon.flags] + elif limit != "": + raise InvalidParameterError(f"Value {limit} for parameter mode is not valid.") + return addons + + +def get_addons_ids(limit: str = "") -> List[str]: + """List addons ids in this Plone site. + + :param limit: Limit list of addons. + 'installed': only products that are installed and not hidden + 'upgradable': only products with upgrades + 'available': products that are not installed but could be + 'non_installable': Non installable products + 'broken': uninstallable products with broken dependencies + :returns: List of addon ids. + """ + addons = get_addons(limit=limit) + return [addon.id for addon in addons] + + +@required_parameters("addon") +def get_version(addon: str) -> str: + """Return the version of the product (package).""" + try: + dist = pkg_resources.get_distribution(addon) + return dist.version + except pkg_resources.DistributionNotFound: + if "." in addon: + return "" + return get_version(f"Products.{addon}") + + +@required_parameters("addon") +def get(addon: str) -> AddonInformation: + """Information about an Addon. + + :param addon: ID of the addon to be retrieved. + :returns: Addon information. + """ + addons = dict(_cached_addons()) + if addon not in addons: + raise InvalidParameterError(f"No addon {addon} found.") + return _update_addon_info(_get_installer(), addons.get(addon)) + + +@required_parameters("addon") +def install(addon: str) -> bool: + """Install an addon. + + :param addon: ID of the addon to be installed. + :returns: Status of the installation. + """ + installer = _get_installer() + return installer.install_product(addon) + + +@required_parameters("addon") +def uninstall(addon: str) -> bool: + """Uninstall an addon. + + :param addon: ID of the addon to be uninstalled. + :returns: Status of the uninstallation. + """ + installer = _get_installer() + return installer.uninstall_product(addon) diff --git a/src/plone/api/tests/test_addon.py b/src/plone/api/tests/test_addon.py new file mode 100644 index 00000000..88bf2862 --- /dev/null +++ b/src/plone/api/tests/test_addon.py @@ -0,0 +1,109 @@ +"""Tests for plone.api.redirection methods.""" +from plone import api +from plone.api.addon import AddonInformation +from plone.api.tests.base import INTEGRATION_TESTING + +import unittest + + +ADDON = "plone.app.multilingual" + + +class TestAPIAddonGetAddons(unittest.TestCase): + """TestCase for plone.api.addon.get_addons.""" + + layer = INTEGRATION_TESTING + + def setUp(self): + """Set up TestCase.""" + self.portal = self.layer["portal"] + # Install plone.app.multilingual + api.addon.install(ADDON) + + def test_api_get_addons(self): + """Test api.addon.get_addons without any filter.""" + result = api.addon.get_addons() + self.assertIsInstance(result, list) + addon_ids = [addon.id for addon in result] + self.assertIn(ADDON, addon_ids) + + def test_api_get_addons_limit_broken(self): + """Test api.addon.get_addons filtering for broken addons.""" + result = api.addon.get_addons(limit="broken") + self.assertEqual(len(result), 0) + + def test_api_get_addons_limit_non_installable(self): + """Test api.addon.get_addons filtering for non_installable addons.""" + result = api.addon.get_addons(limit="non_installable") + self.assertNotEqual(len(result), 0) + addon_ids = [addon.id for addon in result] + self.assertIn("plone.app.dexterity", addon_ids) + + def test_api_get_addons_limit_installed(self): + """Test api.addon.get_addons filtering for installed addons.""" + result = api.addon.get_addons(limit="installed") + self.assertEqual(len(result), 2) + addon_ids = [addon.id for addon in result] + self.assertIn(ADDON, addon_ids) + + def test_api_get_addons_limit_upgradable(self): + """Test api.addon.get_addons filtering for addons with upgradable.""" + result = api.addon.get_addons(limit="upgradable") + self.assertEqual(len(result), 0) + + def test_api_get_addons_limit_invalid(self): + """Test api.addon.get_addons filtering with an invalid parameter.""" + with self.assertRaises(api.exc.InvalidParameterError) as cm: + api.addon.get_addons(limit="foobar") + self.assertIn( + "Value foobar for parameter mode is not valid.", str(cm.exception) + ) + + def test_api_get_addons_ids(self): + """Test api.addon.get_addons_ids.""" + result = api.addon.get_addons_ids(limit="installed") + self.assertEqual(len(result), 2) + self.assertIn(ADDON, result) + + +class TestAPIAddon(unittest.TestCase): + """TestCase for plone.api.addon.""" + + layer = INTEGRATION_TESTING + + def setUp(self): + """Set up TestCase.""" + self.portal = self.layer["portal"] + + def test_api_install(self): + """Test api.addon.install.""" + result = api.addon.install(ADDON) + self.assertTrue(result) + + def test_api_uninstall(self): + """Test api.addon.uninstall.""" + # First install the addon + api.addon.install(ADDON) + result = api.addon.uninstall(ADDON) + self.assertTrue(result) + + def test_api_uninstall_unavailable(self): + """Test api.addon.uninstall unavailable addon.""" + result = api.addon.uninstall("Foobar") + self.assertFalse(result) + + def test_api_get(self): + """Test api.addon.get.""" + result = api.addon.get(ADDON) + self.assertIsInstance(result, AddonInformation) + self.assertEqual(result.id, ADDON) + self.assertTrue(result.valid) + self.assertEqual( + result.description, + "Install to enable multilingual content support with plone.app.multilingual" + ) + self.assertEqual(result.profile_type, "default") + self.assertIsInstance(result.version, str) + self.assertIsInstance(result.install_profile, dict) + self.assertIsInstance(result.uninstall_profile, dict) + self.assertIsInstance(result.upgrade_info, dict)