plone.api 2.4.0__tar.gz → 2.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {plone_api-2.4.0 → plone_api-2.5.0}/CHANGES.md +14 -0
  2. {plone_api-2.4.0 → plone_api-2.5.0}/PKG-INFO +15 -1
  3. plone_api-2.5.0/docs/addon.md +155 -0
  4. {plone_api-2.4.0 → plone_api-2.5.0}/docs/index.md +9 -7
  5. {plone_api-2.4.0 → plone_api-2.5.0}/pyproject.toml +1 -1
  6. {plone_api-2.4.0 → plone_api-2.5.0}/setup.py +1 -1
  7. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/__init__.py +1 -0
  8. plone_api-2.5.0/src/plone/api/addon.py +303 -0
  9. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/content.py +17 -2
  10. plone_api-2.5.0/src/plone/api/tests/doctests/addon.md +155 -0
  11. plone_api-2.5.0/src/plone/api/tests/test_addon.py +106 -0
  12. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone.api.egg-info/PKG-INFO +15 -1
  13. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone.api.egg-info/SOURCES.txt +4 -0
  14. {plone_api-2.4.0 → plone_api-2.5.0}/tox.ini +7 -5
  15. {plone_api-2.4.0 → plone_api-2.5.0}/CONTRIBUTING.md +0 -0
  16. {plone_api-2.4.0 → plone_api-2.5.0}/LICENSE +0 -0
  17. {plone_api-2.4.0 → plone_api-2.5.0}/MANIFEST.in +0 -0
  18. {plone_api-2.4.0 → plone_api-2.5.0}/README.md +0 -0
  19. {plone_api-2.4.0 → plone_api-2.5.0}/docs/about.md +0 -0
  20. {plone_api-2.4.0 → plone_api-2.5.0}/docs/content.md +0 -0
  21. {plone_api-2.4.0 → plone_api-2.5.0}/docs/contribute.md +0 -0
  22. {plone_api-2.4.0 → plone_api-2.5.0}/docs/env.md +0 -0
  23. {plone_api-2.4.0 → plone_api-2.5.0}/docs/group.md +0 -0
  24. {plone_api-2.4.0 → plone_api-2.5.0}/docs/portal.md +0 -0
  25. {plone_api-2.4.0 → plone_api-2.5.0}/docs/relation.md +0 -0
  26. {plone_api-2.4.0 → plone_api-2.5.0}/docs/user.md +0 -0
  27. {plone_api-2.4.0 → plone_api-2.5.0}/setup.cfg +0 -0
  28. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/__init__.py +0 -0
  29. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/configure.zcml +0 -0
  30. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/env.py +0 -0
  31. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/exc.py +0 -0
  32. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/group.py +0 -0
  33. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/portal.py +0 -0
  34. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/profiles/testfixture/metadata.xml +0 -0
  35. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/profiles/testfixture/types/Dexterity_Folder.xml +0 -0
  36. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/profiles/testfixture/types/Dexterity_Item.xml +0 -0
  37. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/profiles/testfixture/types.xml +0 -0
  38. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/relation.py +0 -0
  39. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/testing.zcml +0 -0
  40. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/Dexterity_Folder.xml +0 -0
  41. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/Dexterity_Item.xml +0 -0
  42. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/__init__.py +0 -0
  43. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/base.py +0 -0
  44. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/doctests/about.md +0 -0
  45. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/doctests/content.md +0 -0
  46. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/doctests/contribute.md +0 -0
  47. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/doctests/env.md +0 -0
  48. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/doctests/group.md +0 -0
  49. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/doctests/portal.md +0 -0
  50. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/doctests/relation.md +0 -0
  51. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/doctests/user.md +0 -0
  52. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/test_content.py +0 -0
  53. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/test_doctests.py +0 -0
  54. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/test_env.py +0 -0
  55. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/test_group.py +0 -0
  56. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/test_portal.py +0 -0
  57. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/test_relation.py +0 -0
  58. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/test_user.py +0 -0
  59. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/tests/test_validation.py +0 -0
  60. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/user.py +0 -0
  61. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone/api/validation.py +0 -0
  62. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone.api.egg-info/dependency_links.txt +0 -0
  63. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone.api.egg-info/namespace_packages.txt +0 -0
  64. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone.api.egg-info/not-zip-safe +0 -0
  65. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone.api.egg-info/requires.txt +0 -0
  66. {plone_api-2.4.0 → plone_api-2.5.0}/src/plone.api.egg-info/top_level.txt +0 -0
@@ -9,6 +9,20 @@
9
9
 
10
10
  <!-- towncrier release notes start -->
11
11
 
12
+ ## 2.5.0 (2025-03-25)
13
+
14
+
15
+ ### New features:
16
+
17
+ - Implement `plone.api.addon` module. @ericof, @ujsquared, @stevepiercy #505
18
+
19
+ ## 2.4.1 (2025-03-17)
20
+
21
+
22
+ ### Bug fixes:
23
+
24
+ - Attempt to generate a random temporary id for a content type up to 100 times, else continue to raise a `zExceptions.BadRequest` error. @rohnsha0 #445
25
+
12
26
  ## 2.4.0 (2025-03-14)
13
27
 
14
28
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: plone.api
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: A Plone API.
5
5
  Home-page: https://github.com/plone/plone.api
6
6
  Author: Plone Foundation
@@ -127,6 +127,20 @@ Continuous Integration
127
127
 
128
128
  <!-- towncrier release notes start -->
129
129
 
130
+ ## 2.5.0 (2025-03-25)
131
+
132
+
133
+ ### New features:
134
+
135
+ - Implement `plone.api.addon` module. @ericof, @ujsquared, @stevepiercy #505
136
+
137
+ ## 2.4.1 (2025-03-17)
138
+
139
+
140
+ ### Bug fixes:
141
+
142
+ - Attempt to generate a random temporary id for a content type up to 100 times, else continue to raise a `zExceptions.BadRequest` error. @rohnsha0 #445
143
+
130
144
  ## 2.4.0 (2025-03-14)
131
145
 
132
146
 
@@ -0,0 +1,155 @@
1
+ ---
2
+ myst:
3
+ html_meta:
4
+ "description": "Get, modify, and manage Plone add-ons"
5
+ "property=og:description": "Get, modify, and manage Plone add-ons"
6
+ "property=og:title": "Get, modify, and manage Plone add-ons"
7
+ "keywords": "Plone, API, development, add-on, manage"
8
+ ---
9
+
10
+ ```{eval-rst}
11
+ .. module:: plone
12
+ ```
13
+
14
+ (chapter-addons)=
15
+
16
+ # Add-ons
17
+
18
+ This chapter describes how to get, update, install, uninstall, and manage Plone add-ons.
19
+
20
+
21
+ (addons-get-addons)=
22
+
23
+ ## Get add-ons
24
+
25
+ To get all the add-ons present in the current Plone site, use the {func}`api.addon.get_addons` function.
26
+ The function accepts an optional `limit` parameter to filter the returned add-ons.
27
+ `limit` may be one of the following strings.
28
+
29
+ `available`
30
+ : Products that are not installed, but could be.
31
+
32
+ `broken`
33
+ : Uninstallable products with broken dependencies.
34
+
35
+ `installed`
36
+ : Only products that are installed and not hidden.
37
+
38
+ `non_installable`
39
+ : Non-installable products.
40
+
41
+ `upgradable`
42
+ : Only products with upgrades.
43
+
44
+ The following examples demonstrate usage of the {func}`api.addon.get_addons` function.
45
+
46
+ ```python
47
+ from plone import api
48
+
49
+ # Get all add-ons
50
+ addons = api.addon.get_addons()
51
+
52
+ # Get only installed add-ons
53
+ installed = api.addon.get_addons(limit="installed")
54
+
55
+ # Get only upgradable add-ons
56
+ upgradable = api.addon.get_addons(limit="upgradable")
57
+
58
+ # Get only broken add-ons
59
+ broken = api.addon.get_addons(limit="broken")
60
+ ```
61
+
62
+ (addons-get-addon-ids)=
63
+
64
+ ## Get add-on IDs
65
+
66
+ To get the IDs of all the add-ons present in the current Plone site, use the {func}`api.addon.get_addon_ids` function.
67
+ The function accepts an optional `limit` parameter to filter the returned add-ons, exactly the same as the {func}`api.addon.get_addons` function.
68
+ `limit` may be one of the following strings.
69
+
70
+ `available`
71
+ : Products that are not installed, but could be.
72
+
73
+ `broken`
74
+ : Uninstallable products with broken dependencies.
75
+
76
+ `installed`
77
+ : Only products that are installed and not hidden.
78
+
79
+ `non_installable`
80
+ : Non-installable products.
81
+
82
+ `upgradable`
83
+ : Only products with upgrades.
84
+
85
+ The following example demonstrates usage of the {func}`api.addon.get_addon_ids` function.
86
+
87
+ ```python
88
+ # Get IDs of installed add-ons
89
+ addon_ids = api.addon.get_addon_ids(limit="installed")
90
+ ```
91
+
92
+ ## Get add-on information
93
+
94
+ To get information about a specific add-on, use the {func}`api.addon.get` function, passing in the name of the add-on as a string.
95
+
96
+ ```python
97
+ from plone import api
98
+
99
+ addon = api.addon.get("plone.session")
100
+ print(addon.id) # ID of the add-on
101
+ print(addon.version) # Version string
102
+ print(addon.title) # Display title
103
+ print(addon.description) # Description
104
+ print(addon.flags) # List of flags like ["installed", "upgradable"]
105
+ ```
106
+
107
+ ## Install and uninstall add-ons
108
+
109
+ To install an add-on, use the {func}`api.addon.install` function, passing in the name of the add-on as a string, as shown in the following example.
110
+
111
+ ```python
112
+ from plone import api
113
+
114
+ success = api.addon.install("plone.session")
115
+ ```
116
+
117
+ This function returns a `false` boolean value in the following cases.
118
+ - The installation fails due to an error.
119
+ - The add-on is already installed.
120
+ - The add-on is not found among the available add-ons.
121
+
122
+
123
+ To uninstall an add-on, use the {func}`api.addon.uninstall` function, passing in the name of the add-on as a string, as shown in the following example.
124
+
125
+
126
+ ```python
127
+ from plone import api
128
+
129
+ success = api.addon.uninstall("plone.session")
130
+ ```
131
+
132
+ This function returns a `false` boolean value in the following cases.
133
+ - The removal of add-on fails due to an error.
134
+ - The add-on is not installed.
135
+
136
+ ## Get add-on version
137
+
138
+ To get the version of an add-on, use the {func}`api.addon.get_version` function, passing in the name of the add-on as a string, as shown in the following example.
139
+
140
+ ```python
141
+ from plone import api
142
+
143
+ version = api.addon.get_version("plone.session")
144
+ ```
145
+
146
+ Note that this returns the version of the Python package installed from _PyPI_, not the version of the add-on's _GenericSetup_ profile.
147
+
148
+ (addons-exceptions)=
149
+
150
+ ## Exceptions
151
+
152
+ The {exc}`~plone.api.exc.InvalidParameterError` exception may be raised in the following cases.
153
+
154
+ - When using the {func}`api.addon.get` function, trying to get information about a non-existent add-on.
155
+ - When using either function {func}`api.addon.get_addons` or {func}`api.addon.get_addon_ids`, using an invalid `limit` parameter value.
@@ -39,12 +39,13 @@ Backward-incompatible changes to the API will be restricted to major versions (1
39
39
  :maxdepth: 2
40
40
 
41
41
  about
42
- portal
42
+ addon
43
43
  content
44
- user
45
- group
46
44
  env
45
+ group
46
+ portal
47
47
  relation
48
+ user
48
49
  ```
49
50
 
50
51
 
@@ -58,13 +59,14 @@ api/index
58
59
  ```
59
60
 
60
61
  - {doc}`api/index`
61
- - [`plone.api.portal`](api/portal)
62
+ - [`plone.api.addon`](api/addon)
62
63
  - [`plone.api.content`](api/content)
63
- - [`plone.api.user`](api/user)
64
- - [`plone.api.group`](api/group)
65
64
  - [`plone.api.env`](api/env)
66
- - [`plone.api.relation`](api/relation)
67
65
  - [`plone.api.exceptions`](api/exceptions)
66
+ - [`plone.api.group`](api/group)
67
+ - [`plone.api.portal`](api/portal)
68
+ - [`plone.api.relation`](api/relation)
69
+ - [`plone.api.user`](api/user)
68
70
 
69
71
 
70
72
  ## Contribute
@@ -2,7 +2,7 @@
2
2
  # https://github.com/plone/meta/tree/main/config/default
3
3
  # See the inline comments on how to expand/tweak this configuration file
4
4
  [build-system]
5
- requires = ["setuptools>=68.2"]
5
+ requires = ["setuptools>=68.2,<77"]
6
6
 
7
7
  [tool.towncrier]
8
8
  directory = "news/"
@@ -3,7 +3,7 @@ from setuptools import find_packages
3
3
  from setuptools import setup
4
4
 
5
5
 
6
- version = "2.4.0"
6
+ version = "2.5.0"
7
7
 
8
8
  long_description = "\n".join(
9
9
  [Path("README.md").read_text(), Path("CHANGES.md").read_text()]
@@ -1,5 +1,6 @@
1
1
  # flake8: NOQA: S401
2
2
 
3
+ from plone.api import addon
3
4
  from plone.api import content
4
5
  from plone.api import env
5
6
  from plone.api import group
@@ -0,0 +1,303 @@
1
+ """API to handle add-on management."""
2
+
3
+ from dataclasses import dataclass
4
+ from functools import lru_cache
5
+ from plone.api import portal
6
+ from plone.api.exc import InvalidParameterError
7
+ from plone.api.validation import required_parameters
8
+ from Products.CMFPlone.controlpanel.browser.quickinstaller import InstallerView
9
+ from Products.CMFPlone.interfaces import INonInstallable
10
+ from Products.CMFPlone.utils import get_installer
11
+ from Products.GenericSetup import EXTENSION
12
+ from typing import Dict
13
+ from typing import List
14
+ from typing import Tuple
15
+ from zope.component import getAllUtilitiesRegisteredFor
16
+ from zope.globalrequest import getRequest
17
+
18
+ import logging
19
+ import pkg_resources
20
+
21
+
22
+ logger = logging.getLogger("plone.api.addon")
23
+
24
+
25
+ __all__ = [
26
+ "AddonInformation",
27
+ "NonInstallableAddons",
28
+ "get_addons",
29
+ "get_addon_ids",
30
+ "get_version",
31
+ "get",
32
+ "install",
33
+ "uninstall",
34
+ ]
35
+
36
+
37
+ @dataclass
38
+ class NonInstallableAddons:
39
+ """Set of add-ons not available for installation."""
40
+
41
+ profiles: List[str]
42
+ products: List[str]
43
+
44
+
45
+ @dataclass
46
+ class AddonInformation:
47
+ """Add-on information."""
48
+
49
+ id: str # noQA
50
+ version: str
51
+ title: str
52
+ description: str
53
+
54
+ upgrade_profiles: Dict
55
+ other_profiles: List[List]
56
+ install_profile: Dict
57
+ uninstall_profile: Dict
58
+ profile_type: str
59
+ upgrade_info: Dict
60
+ valid: bool
61
+ flags: List[str]
62
+
63
+ def __repr__(self) -> str:
64
+ """Return a string representation of this object."""
65
+ return f"<AddonInformation id='{self.id}' flags='{self.flags}'>"
66
+
67
+
68
+ def _get_installer() -> InstallerView:
69
+ """Return the InstallerView."""
70
+ portal_obj = portal.get()
71
+ return get_installer(portal_obj, getRequest())
72
+
73
+
74
+ @lru_cache(maxsize=1)
75
+ def _get_non_installable_addons() -> NonInstallableAddons:
76
+ """Return information about non installable add-ons.
77
+
78
+ We cache this on first use, as those utilities are registered
79
+ during the application startup
80
+
81
+ :returns: NonInstallableAddons instance.
82
+ """
83
+ ignore_profiles = []
84
+ ignore_products = []
85
+ utils = getAllUtilitiesRegisteredFor(INonInstallable)
86
+ for util in utils:
87
+ ni_profiles = getattr(util, "getNonInstallableProfiles", None)
88
+ if ni_profiles is not None:
89
+ ignore_profiles.extend(ni_profiles())
90
+ ni_products = getattr(util, "getNonInstallableProducts", None)
91
+ if ni_products is not None:
92
+ ignore_products.extend(ni_products())
93
+ return NonInstallableAddons(
94
+ profiles=ignore_profiles,
95
+ products=ignore_products,
96
+ )
97
+
98
+
99
+ @lru_cache(maxsize=1)
100
+ def _cached_addons() -> Tuple[Tuple[str, AddonInformation]]:
101
+ """Return information about add-ons in this installation.
102
+
103
+ :returns: Tuple of tuples with add-on id and AddonInformation.
104
+ :rtype: Tuple
105
+ """
106
+ installer = _get_installer()
107
+ setup_tool = installer.ps
108
+ addons = {}
109
+ non_installable = _get_non_installable_addons()
110
+ # Known profiles:
111
+ profiles = setup_tool.listProfileInfo()
112
+
113
+ for profile in profiles:
114
+ if profile["type"] != EXTENSION:
115
+ continue
116
+
117
+ pid = profile["id"]
118
+ if pid in non_installable.profiles:
119
+ continue
120
+ pid_parts = pid.split(":")
121
+ if len(pid_parts) != 2:
122
+ logger.error(f"Profile with id '{pid}' is invalid.")
123
+ # Which package (product) is this from?
124
+ product_id = profile["product"]
125
+ flags = []
126
+ is_broken = not installer.is_product_installable(product_id, allow_hidden=True)
127
+ is_non_installable = product_id in non_installable.products
128
+ valid = not (is_broken or is_non_installable)
129
+ if is_broken:
130
+ flags.append("broken")
131
+ if is_non_installable:
132
+ flags.append("non_installable")
133
+ profile_type = pid_parts[-1]
134
+ if product_id not in addons:
135
+ # get some basic information on the product
136
+ product = {
137
+ "id": product_id,
138
+ "version": get_version(product_id),
139
+ "title": product_id,
140
+ "description": "",
141
+ "upgrade_profiles": {},
142
+ "other_profiles": [],
143
+ "install_profile": {},
144
+ "uninstall_profile": {},
145
+ "upgrade_info": {},
146
+ "profile_type": profile_type,
147
+ "valid": valid,
148
+ "flags": flags,
149
+ }
150
+ install_profile = installer.get_install_profile(product_id)
151
+ if install_profile is not None:
152
+ product["title"] = install_profile["title"]
153
+ product["description"] = install_profile["description"]
154
+ product["install_profile"] = install_profile
155
+ product["profile_type"] = "default"
156
+ uninstall_profile = installer.get_uninstall_profile(product_id)
157
+ if uninstall_profile is not None:
158
+ product["uninstall_profile"] = uninstall_profile
159
+ # Do not override profile_type.
160
+ if not product["profile_type"]:
161
+ product["profile_type"] = "uninstall"
162
+ if "version" in profile:
163
+ product["upgrade_profiles"][profile["version"]] = profile
164
+ else:
165
+ product["other_profiles"].append(profile)
166
+ addons[product_id] = AddonInformation(**product)
167
+ return tuple(addons.items())
168
+
169
+
170
+ def _update_addon_info(
171
+ addon: AddonInformation, installer: InstallerView
172
+ ) -> AddonInformation:
173
+ """Update information about an add-on.
174
+
175
+ :param addon: [required] Add-on object to be updated
176
+ :type addon: AddonInformation object
177
+ :param installer: InstallerView object to check for add-on info
178
+ :type installer: InstallerView object
179
+
180
+ :returns: Updated AddonInformation object
181
+ :rtype: AddonInformation object
182
+ """
183
+ addon_id = addon.id
184
+ if addon.valid:
185
+ flags = []
186
+ # Update only what could be changed
187
+ is_installed = installer.is_product_installed(addon_id)
188
+ if is_installed:
189
+ addon.upgrade_info = installer.upgrade_info(addon_id) or {}
190
+ if addon.upgrade_info.get("available"):
191
+ flags.append("upgradable")
192
+ else:
193
+ flags.append("installed")
194
+ else:
195
+ flags.append("available")
196
+ addon.flags = flags
197
+ return addon
198
+
199
+
200
+ def _get_addons() -> List[AddonInformation]:
201
+ """Return an updated list of add-on information.
202
+
203
+ :returns: List of AddonInformation.
204
+ :rtype: List
205
+ """
206
+ installer = _get_installer()
207
+ addons = dict(_cached_addons())
208
+ result = []
209
+ for addon in addons.values():
210
+ result.append(_update_addon_info(addon, installer))
211
+ return result
212
+
213
+
214
+ def get_addons(limit: str = "") -> List[AddonInformation]:
215
+ """List add-ons in this Plone site.
216
+
217
+ :param limit: Limit list of add-ons.
218
+ 'installed': only products that are installed and not hidden
219
+ 'upgradable': only products with upgrades
220
+ 'available': products that are not installed but could be
221
+ 'non_installable': Non installable products
222
+ 'broken': uninstallable products with broken dependencies
223
+ :type limit: string
224
+ :returns: List of AddonInformation.
225
+ :raises:
226
+ InvalidParameterError
227
+ :Example: :ref:`addons-get-addons`
228
+ """
229
+ addons = _get_addons()
230
+ if limit in ("non_installable", "broken"):
231
+ return [addon for addon in addons if limit in addon.flags]
232
+
233
+ addons = [addon for addon in addons if addon.valid]
234
+ if limit in ("installed", "upgradable", "available"):
235
+ addons = [addon for addon in addons if limit in addon.flags]
236
+ elif limit != "":
237
+ raise InvalidParameterError(f"Parameter limit='{limit}' is not valid.")
238
+ return addons
239
+
240
+
241
+ def get_addon_ids(limit: str = "") -> List[str]:
242
+ """List add-ons ids in this Plone site.
243
+
244
+ :param limit: Limit list of add-ons.
245
+ 'installed': only products that are installed and not hidden
246
+ 'upgradable': only products with upgrades
247
+ 'available': products that are not installed but could be
248
+ 'non_installable': Non installable products
249
+ 'broken': uninstallable products with broken dependencies
250
+ :type limit: string
251
+ :returns: List of add-on ids.
252
+ """
253
+ addons = get_addons(limit=limit)
254
+ return [addon.id for addon in addons]
255
+
256
+
257
+ @required_parameters("addon")
258
+ def get_version(addon: str) -> str:
259
+ """Return the version of the product (package)."""
260
+ try:
261
+ dist = pkg_resources.get_distribution(addon)
262
+ return dist.version
263
+ except pkg_resources.DistributionNotFound:
264
+ if "." in addon:
265
+ return ""
266
+ return get_version(f"Products.{addon}")
267
+
268
+
269
+ @required_parameters("addon")
270
+ def get(addon: str) -> AddonInformation:
271
+ """Information about an Add-on.
272
+
273
+ :param addon: ID of the add-on to be retrieved.
274
+ :returns: Add-on information.
275
+ :rtype: string
276
+ """
277
+ addons = dict(_cached_addons())
278
+ if addon not in addons:
279
+ raise InvalidParameterError(f"No add-on {addon} found.")
280
+ return _update_addon_info(addons.get(addon), _get_installer())
281
+
282
+
283
+ @required_parameters("addon")
284
+ def install(addon: str) -> bool:
285
+ """Install an add-on.
286
+
287
+ :param addon: ID of the add-on to be installed.
288
+ :returns: Status of the installation.
289
+ """
290
+ installer = _get_installer()
291
+ return installer.install_product(addon)
292
+
293
+
294
+ @required_parameters("addon")
295
+ def uninstall(addon: str) -> bool:
296
+ """Uninstall an add-on.
297
+
298
+ :param addon: ID of the add-on to be uninstalled.
299
+ :returns: Status of the uninstallation.
300
+ :rtype: Boolean value representing the status of the uninstallation.
301
+ """
302
+ installer = _get_installer()
303
+ return installer.uninstall_product(addon)
@@ -22,12 +22,15 @@ from zope.globalrequest import getRequest
22
22
  from zope.interface import Interface
23
23
  from zope.interface import providedBy
24
24
 
25
- import random
26
25
  import transaction
26
+ import uuid
27
27
 
28
28
 
29
29
  _marker = []
30
30
 
31
+ # Maximum number of attempts to generate a unique random ID
32
+ MAX_UNIQUE_ID_ATTEMPTS = 100
33
+
31
34
 
32
35
  @required_parameters("container", "type")
33
36
  @at_least_one_of("id", "title")
@@ -67,7 +70,19 @@ def create(
67
70
  :Example: :ref:`content-create-example`
68
71
  """
69
72
  # Create a temporary id if the id is not given
70
- content_id = not safe_id and id or str(random.randint(0, 99999999))
73
+ if not safe_id and id:
74
+ content_id = id
75
+ else:
76
+ # Try to generate a unique random ID using UUID4
77
+ attempts = 0
78
+ while attempts < MAX_UNIQUE_ID_ATTEMPTS:
79
+ content_id = str(uuid.uuid4())
80
+ if content_id not in container:
81
+ break
82
+ attempts += 1
83
+ # If we couldn't find a unique ID after max attempts, raise ValueError
84
+ if attempts >= MAX_UNIQUE_ID_ATTEMPTS:
85
+ raise ValueError("Could not find unique id while creating content.")
71
86
 
72
87
  if title:
73
88
  kwargs["title"] = title
@@ -0,0 +1,155 @@
1
+ ---
2
+ myst:
3
+ html_meta:
4
+ "description": "Get, modify, and manage Plone add-ons"
5
+ "property=og:description": "Get, modify, and manage Plone add-ons"
6
+ "property=og:title": "Get, modify, and manage Plone add-ons"
7
+ "keywords": "Plone, API, development, add-on, manage"
8
+ ---
9
+
10
+ ```{eval-rst}
11
+ .. module:: plone
12
+ ```
13
+
14
+ (chapter-addons)=
15
+
16
+ # Add-ons
17
+
18
+ This chapter describes how to get, update, install, uninstall, and manage Plone add-ons.
19
+
20
+
21
+ (addons-get-addons)=
22
+
23
+ ## Get add-ons
24
+
25
+ To get all the add-ons present in the current Plone site, use the {func}`api.addon.get_addons` function.
26
+ The function accepts an optional `limit` parameter to filter the returned add-ons.
27
+ `limit` may be one of the following strings.
28
+
29
+ `available`
30
+ : Products that are not installed, but could be.
31
+
32
+ `broken`
33
+ : Uninstallable products with broken dependencies.
34
+
35
+ `installed`
36
+ : Only products that are installed and not hidden.
37
+
38
+ `non_installable`
39
+ : Non-installable products.
40
+
41
+ `upgradable`
42
+ : Only products with upgrades.
43
+
44
+ The following examples demonstrate usage of the {func}`api.addon.get_addons` function.
45
+
46
+ ```python
47
+ from plone import api
48
+
49
+ # Get all add-ons
50
+ addons = api.addon.get_addons()
51
+
52
+ # Get only installed add-ons
53
+ installed = api.addon.get_addons(limit="installed")
54
+
55
+ # Get only upgradable add-ons
56
+ upgradable = api.addon.get_addons(limit="upgradable")
57
+
58
+ # Get only broken add-ons
59
+ broken = api.addon.get_addons(limit="broken")
60
+ ```
61
+
62
+ (addons-get-addon-ids)=
63
+
64
+ ## Get add-on IDs
65
+
66
+ To get the IDs of all the add-ons present in the current Plone site, use the {func}`api.addon.get_addon_ids` function.
67
+ The function accepts an optional `limit` parameter to filter the returned add-ons, exactly the same as the {func}`api.addon.get_addons` function.
68
+ `limit` may be one of the following strings.
69
+
70
+ `available`
71
+ : Products that are not installed, but could be.
72
+
73
+ `broken`
74
+ : Uninstallable products with broken dependencies.
75
+
76
+ `installed`
77
+ : Only products that are installed and not hidden.
78
+
79
+ `non_installable`
80
+ : Non-installable products.
81
+
82
+ `upgradable`
83
+ : Only products with upgrades.
84
+
85
+ The following example demonstrates usage of the {func}`api.addon.get_addon_ids` function.
86
+
87
+ ```python
88
+ # Get IDs of installed add-ons
89
+ addon_ids = api.addon.get_addon_ids(limit="installed")
90
+ ```
91
+
92
+ ## Get add-on information
93
+
94
+ To get information about a specific add-on, use the {func}`api.addon.get` function, passing in the name of the add-on as a string.
95
+
96
+ ```python
97
+ from plone import api
98
+
99
+ addon = api.addon.get("plone.session")
100
+ print(addon.id) # ID of the add-on
101
+ print(addon.version) # Version string
102
+ print(addon.title) # Display title
103
+ print(addon.description) # Description
104
+ print(addon.flags) # List of flags like ["installed", "upgradable"]
105
+ ```
106
+
107
+ ## Install and uninstall add-ons
108
+
109
+ To install an add-on, use the {func}`api.addon.install` function, passing in the name of the add-on as a string, as shown in the following example.
110
+
111
+ ```python
112
+ from plone import api
113
+
114
+ success = api.addon.install("plone.session")
115
+ ```
116
+
117
+ This function returns a `false` boolean value in the following cases.
118
+ - The installation fails due to an error.
119
+ - The add-on is already installed.
120
+ - The add-on is not found among the available add-ons.
121
+
122
+
123
+ To uninstall an add-on, use the {func}`api.addon.uninstall` function, passing in the name of the add-on as a string, as shown in the following example.
124
+
125
+
126
+ ```python
127
+ from plone import api
128
+
129
+ success = api.addon.uninstall("plone.session")
130
+ ```
131
+
132
+ This function returns a `false` boolean value in the following cases.
133
+ - The removal of add-on fails due to an error.
134
+ - The add-on is not installed.
135
+
136
+ ## Get add-on version
137
+
138
+ To get the version of an add-on, use the {func}`api.addon.get_version` function, passing in the name of the add-on as a string, as shown in the following example.
139
+
140
+ ```python
141
+ from plone import api
142
+
143
+ version = api.addon.get_version("plone.session")
144
+ ```
145
+
146
+ Note that this returns the version of the Python package installed from _PyPI_, not the version of the add-on's _GenericSetup_ profile.
147
+
148
+ (addons-exceptions)=
149
+
150
+ ## Exceptions
151
+
152
+ The {exc}`~plone.api.exc.InvalidParameterError` exception may be raised in the following cases.
153
+
154
+ - When using the {func}`api.addon.get` function, trying to get information about a non-existent add-on.
155
+ - When using either function {func}`api.addon.get_addons` or {func}`api.addon.get_addon_ids`, using an invalid `limit` parameter value.
@@ -0,0 +1,106 @@
1
+ """Tests for plone.api.addon methods."""
2
+
3
+ from plone import api
4
+ from plone.api.addon import AddonInformation
5
+ from plone.api.tests.base import INTEGRATION_TESTING
6
+
7
+ import unittest
8
+
9
+
10
+ ADDON = "plone.session"
11
+
12
+
13
+ class TestAPIAddonGetAddons(unittest.TestCase):
14
+ """TestCase for plone.api.addon.get_addons."""
15
+
16
+ layer = INTEGRATION_TESTING
17
+
18
+ def setUp(self):
19
+ """Set up TestCase."""
20
+ self.portal = self.layer["portal"]
21
+ # Install plone.app.multilingual
22
+ api.addon.install(ADDON)
23
+
24
+ def test_api_get_addons(self):
25
+ """Test api.addon.get_addons without any filter."""
26
+ result = api.addon.get_addons()
27
+ self.assertIsInstance(result, list)
28
+ addon_ids = [addon.id for addon in result]
29
+ self.assertIn(ADDON, addon_ids)
30
+
31
+ def test_api_get_addons_limit_broken(self):
32
+ """Test api.addon.get_addons filtering for broken add-ons."""
33
+ result = api.addon.get_addons(limit="broken")
34
+ self.assertEqual(len(result), 0)
35
+
36
+ def test_api_get_addons_limit_non_installable(self):
37
+ """Test api.addon.get_addons filtering for non_installable add-ons."""
38
+ result = api.addon.get_addons(limit="non_installable")
39
+ self.assertNotEqual(len(result), 0)
40
+ addon_ids = [addon.id for addon in result]
41
+ self.assertIn("plone.app.dexterity", addon_ids)
42
+
43
+ def test_api_get_addons_limit_installed(self):
44
+ """Test api.addon.get_addons filtering for installed add-ons."""
45
+ result = api.addon.get_addons(limit="installed")
46
+ self.assertEqual(len(result), 2)
47
+ addon_ids = [addon.id for addon in result]
48
+ self.assertIn(ADDON, addon_ids)
49
+
50
+ def test_api_get_addons_limit_upgradable(self):
51
+ """Test api.addon.get_addons filtering for add-ons with upgradable."""
52
+ result = api.addon.get_addons(limit="upgradable")
53
+ self.assertEqual(len(result), 0)
54
+
55
+ def test_api_get_addons_limit_invalid(self):
56
+ """Test api.addon.get_addons filtering with an invalid parameter."""
57
+ with self.assertRaises(api.exc.InvalidParameterError) as cm:
58
+ api.addon.get_addons(limit="foobar")
59
+ self.assertIn("Parameter limit='foobar' is not valid.", str(cm.exception))
60
+
61
+ def test_api_get_addon_ids(self):
62
+ """Test api.addon.get_addon_ids."""
63
+ result = api.addon.get_addon_ids(limit="installed")
64
+ self.assertEqual(len(result), 2)
65
+ self.assertIn(ADDON, result)
66
+
67
+
68
+ class TestAPIAddon(unittest.TestCase):
69
+ """TestCase for plone.api.addon."""
70
+
71
+ layer = INTEGRATION_TESTING
72
+
73
+ def setUp(self):
74
+ """Set up TestCase."""
75
+ self.portal = self.layer["portal"]
76
+
77
+ def test_api_install(self):
78
+ """Test api.addon.install."""
79
+ result = api.addon.install(ADDON)
80
+ self.assertTrue(result)
81
+
82
+ def test_api_uninstall(self):
83
+ """Test api.addon.uninstall."""
84
+ # First install the add-on
85
+ api.addon.install(ADDON)
86
+ # Then uninstall the add-on
87
+ result = api.addon.uninstall(ADDON)
88
+ self.assertTrue(result)
89
+
90
+ def test_api_uninstall_unavailable(self):
91
+ """Test api.addon.uninstall unavailable add-on."""
92
+ result = api.addon.uninstall("Foobar")
93
+ self.assertFalse(result)
94
+
95
+ def test_api_get(self):
96
+ """Test api.addon.get."""
97
+ result = api.addon.get(ADDON)
98
+ self.assertIsInstance(result, AddonInformation)
99
+ self.assertEqual(result.id, ADDON)
100
+ self.assertTrue(result.valid)
101
+ self.assertEqual(result.description, "Optional plone.session refresh support.")
102
+ self.assertEqual(result.profile_type, "default")
103
+ self.assertIsInstance(result.version, str)
104
+ self.assertIsInstance(result.install_profile, dict)
105
+ self.assertIsInstance(result.uninstall_profile, dict)
106
+ self.assertIsInstance(result.upgrade_info, dict)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: plone.api
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: A Plone API.
5
5
  Home-page: https://github.com/plone/plone.api
6
6
  Author: Plone Foundation
@@ -127,6 +127,20 @@ Continuous Integration
127
127
 
128
128
  <!-- towncrier release notes start -->
129
129
 
130
+ ## 2.5.0 (2025-03-25)
131
+
132
+
133
+ ### New features:
134
+
135
+ - Implement `plone.api.addon` module. @ericof, @ujsquared, @stevepiercy #505
136
+
137
+ ## 2.4.1 (2025-03-17)
138
+
139
+
140
+ ### Bug fixes:
141
+
142
+ - Attempt to generate a random temporary id for a content type up to 100 times, else continue to raise a `zExceptions.BadRequest` error. @rohnsha0 #445
143
+
130
144
  ## 2.4.0 (2025-03-14)
131
145
 
132
146
 
@@ -8,6 +8,7 @@ setup.cfg
8
8
  setup.py
9
9
  tox.ini
10
10
  docs/about.md
11
+ docs/addon.md
11
12
  docs/content.md
12
13
  docs/contribute.md
13
14
  docs/env.md
@@ -25,6 +26,7 @@ src/plone.api.egg-info/not-zip-safe
25
26
  src/plone.api.egg-info/requires.txt
26
27
  src/plone.api.egg-info/top_level.txt
27
28
  src/plone/api/__init__.py
29
+ src/plone/api/addon.py
28
30
  src/plone/api/configure.zcml
29
31
  src/plone/api/content.py
30
32
  src/plone/api/env.py
@@ -43,6 +45,7 @@ src/plone/api/tests/Dexterity_Folder.xml
43
45
  src/plone/api/tests/Dexterity_Item.xml
44
46
  src/plone/api/tests/__init__.py
45
47
  src/plone/api/tests/base.py
48
+ src/plone/api/tests/test_addon.py
46
49
  src/plone/api/tests/test_content.py
47
50
  src/plone/api/tests/test_doctests.py
48
51
  src/plone/api/tests/test_env.py
@@ -52,6 +55,7 @@ src/plone/api/tests/test_relation.py
52
55
  src/plone/api/tests/test_user.py
53
56
  src/plone/api/tests/test_validation.py
54
57
  src/plone/api/tests/doctests/about.md
58
+ src/plone/api/tests/doctests/addon.md
55
59
  src/plone/api/tests/doctests/content.md
56
60
  src/plone/api/tests/doctests/contribute.md
57
61
  src/plone/api/tests/doctests/env.md
@@ -107,7 +107,7 @@ commands =
107
107
  description = run the distribution tests
108
108
  use_develop = true
109
109
  skip_install = false
110
- constrain_package_deps = true
110
+ constrain_package_deps = True
111
111
  set_env =
112
112
  ROBOT_BROWSER=headlesschrome
113
113
 
@@ -153,7 +153,7 @@ extras =
153
153
  description = get a test coverage report
154
154
  use_develop = true
155
155
  skip_install = false
156
- constrain_package_deps = true
156
+ constrain_package_deps = True
157
157
  set_env =
158
158
  ROBOT_BROWSER=headlesschrome
159
159
 
@@ -201,7 +201,7 @@ use_develop = true
201
201
  skip_install = false
202
202
  # Here we must always constrain the package deps to what is already installed,
203
203
  # otherwise we simply get the latest from PyPI, which may not work.
204
- constrain_package_deps = true
204
+ constrain_package_deps = True
205
205
  set_env =
206
206
 
207
207
  ##
@@ -259,6 +259,7 @@ allowlist_externals =
259
259
  # See [testenv:docs] for classic documentation
260
260
  basepython = python3.11
261
261
  skip_install = False
262
+ constrain_package_deps = True
262
263
  package = editable
263
264
  allowlist_externals =
264
265
  mkdir
@@ -275,6 +276,7 @@ commands =
275
276
  # Build docs on Read the Docs to preview pull requests using plone-sphinx-theme
276
277
  basepython = python3.11
277
278
  skip_install = False
279
+ constrain_package_deps = True
278
280
  extras =
279
281
  tests
280
282
  deps =
@@ -306,6 +308,7 @@ whitelist_externals =
306
308
  [testenv:linkcheck]
307
309
  basepython = python3.11
308
310
  skip_install = False
311
+ constrain_package_deps = True
309
312
  package = editable
310
313
  allowlist_externals =
311
314
  mkdir
@@ -321,7 +324,7 @@ commands =
321
324
  [testenv:livehtml]
322
325
  basepython = python3.11
323
326
  skip_install = False
324
- constrain_package_deps = true
327
+ constrain_package_deps = True
325
328
  package = editable
326
329
  allowlist_externals =
327
330
  mkdir
@@ -329,7 +332,6 @@ extras =
329
332
  {[testenv:plone6docs]extras}
330
333
  deps =
331
334
  {[testenv:plone6docs]deps}
332
- -c https://dist.plone.org/release/6.0-dev/constraints.txt
333
335
  commands =
334
336
  python -VV
335
337
  mkdir -p {toxinidir}/_build/plone6docs
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes