Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement plone.api.addons module #506

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 3 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
17 changes: 17 additions & 0 deletions docs/api/addon.md
Original file line number Diff line number Diff line change
@@ -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:
```
1 change: 1 addition & 0 deletions news/505.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implement plone.api.addons module [@ericof]
1 change: 1 addition & 0 deletions src/plone/api/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
283 changes: 283 additions & 0 deletions src/plone/api/addon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
"""API to handle addon management."""
from dataclasses import dataclass
from functools import lru_cache
from plone.api import portal
from plone.api.exc import InvalidParameterError
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"<AddonInformation id='{self.id}' flags='{self.flags}'>"


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.")
ericof marked this conversation as resolved.
Show resolved Hide resolved
return addons


def get_addons_ids(limit: str = "") -> List[str]:
ericof marked this conversation as resolved.
Show resolved Hide resolved
"""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)
Loading