Repository: plone/plone.api
Pull request #562: Add api.content.iter_ancestors
Author: login: ale-rt
Created: 2025-02-25T15:59:19Z | Merged: 2025-03-05T06:07:35Z

Builds on top of #560.

<!-- readthedocs-preview ploneapi start -->
----
📚 Documentation preview 📚: https://ploneapi--562.org.readthedocs.build/

<!-- readthedocs-preview ploneapi end -->


Diff:
diff --git a/docs/content.md b/docs/content.md
index 28cf07dc..51eea508 100644
--- a/docs/content.md
+++ b/docs/content.md
@@ -589,6 +589,100 @@ except InvalidParameterError as e:
     assert "Object not in portal path" in str(e)
 ```
 
+(content-iter-ancestors-example)=
+
+## Iterate over object ancestors
+
+To iterate over the ancestors in the object tree, use the {func}`api.content.iter_ancestors` function.
+
+```python
+from plone import api
+portal = api.portal.get()
+
+# Get all ancestors of the team object
+ancestors = api.content.iter_ancestors(portal.about.team)
+```
+
+% invisible-code-block: python
+%
+% self.assertTupleEqual(tuple(ancestors), (portal.about, portal))
+
+To iterate over the ancestors that implement one interface, use the {func}`api.content.iter_ancestors` function with the `interface` argument.
+
+```python
+from plone import api
+from Products.CMFCore.interfaces import ISiteRoot
+portal = api.portal.get()
+
+# Get all ancestors of the team object that implement the ISiteRoot interface
+ancestors = api.content.iter_ancestors(portal.about.team, interface=ISiteRoot)
+```
+
+% invisible-code-block: python
+%
+% self.assertTupleEqual(tuple(ancestors), (portal,))
+
+To iterate over the ancestors using a custom filter you can use the {func}`api.content.iter_ancestors` function with the `function` argument.
+
+```python
+from plone import api
+portal = api.portal.get()
+
+# Get all ancestors of the team object with the id 'about'
+ancestors = api.content.iter_ancestors(portal.about.team, function=lambda obj: obj.id == "about")
+```
+
+% invisible-code-block: python
+%
+% self.assertTupleEqual(tuple(ancestors), (portal.about,))
+
+To iterate over the ancestors until an object is found, use the {func}`api.content.iter_ancestors` function with the `stop_at` argument.
+
+```python
+from plone import api
+portal = api.portal.get()
+
+# Get all ancestors of the team object until the object 'about'
+ancestors = api.content.iter_ancestors(portal.about.team, stop_at=portal.about)
+```
+
+% invisible-code-block: python
+%
+% self.assertTupleEqual(tuple(ancestors), (portal.about,))
+
+(content-get-closest-ancestor-example)=
+
+## Get closest ancestor
+
+To get the closest ancestor in the object tree, use the {func}`api.content.get_closest_ancestor` function.
+
+```python
+from plone import api
+from Products.CMFCore.interfaces import ISiteRoot
+portal = api.portal.get()
+
+# Get the closest ancestor of the team object that implements the ISiteRoot interface
+ancestor = api.content.get_closest_ancestor(portal.about.team, interface=ISiteRoot)
+```
+
+% invisible-code-block: python
+%
+% self.assertEqual(ancestor, portal)
+
+To get the closest ancestor by using a custom filter, use the {func}`api.content.get_closest_ancestor` function with the `function` argument.
+
+```python
+from plone import api
+portal = api.portal.get()
+
+# Get the closest ancestor of the team object with the id 'about'
+ancestor = api.content.get_closest_ancestor(portal.about.team, function=lambda obj: obj.id == "about")
+```
+
+% invisible-code-block: python
+%
+% self.assertEqual(ancestor, portal.about)
+
 ## Further reading
 
 For more information on possible flags and usage options please see the full {ref}`plone-api-content` specification.
diff --git a/news/531.feature b/news/531.feature
new file mode 100644
index 00000000..f84efadb
--- /dev/null
+++ b/news/531.feature
@@ -0,0 +1 @@
+Add the `api.content.iter_ancestors` function for iterating over an object acquisition chain. @rohnsha0, @ale-rt
diff --git a/setup.py b/setup.py
index ee4ff0e8..c56fdebc 100644
--- a/setup.py
+++ b/setup.py
@@ -29,6 +29,7 @@
     keywords="plone api",
     python_requires=">=3.8",
     install_requires=[
+        "Acquisition",
         "Products.statusmessages",
         "Products.PlonePAS",
         "Products.CMFPlone",
diff --git a/src/plone/api/content.py b/src/plone/api/content.py
index b15314ff..a344e0c2 100644
--- a/src/plone/api/content.py
+++ b/src/plone/api/content.py
@@ -1,6 +1,9 @@
 """Module that provides functionality for content manipulation."""
 
+from Acquisition import aq_chain
+from Acquisition import aq_inner
 from copy import copy as _copy
+from itertools import islice
 from plone.api import portal
 from plone.api.exc import InvalidParameterError
 from plone.api.validation import at_least_one_of
@@ -696,3 +699,94 @@ def find(context=None, depth=None, unrestricted=False, **kwargs):
         return catalog.unrestrictedSearchResults(**query)
     else:
         return catalog(**query)
+
+
+@required_parameters("obj")
+def iter_ancestors(obj=None, function=None, interface=None, stop_at=_marker):
+    """Iterate over the object ancestors.
+
+    Optionally filter the ancestors:
+
+    1. by a custom function.
+    2. by interface.
+
+    The iteration will stop by default at the portal root.
+    If you want to stop at a specific object, pass it as the ``stop_at`` parameter.
+    If you want to return all the matching objects in the acquisition chain, pass
+    ``False`` as the value of the ``stop_at`` parameter.
+
+    :param obj: [required] Object for which we want to iterate over the ancestors.
+    :type obj: Content object
+    :param function: Optional callable that takes an object and returns a boolean.
+    :type function: callable
+    :param interface: Optional interface that should be provided by the ancestor.
+    :type interface: zope.interface.Interface
+    :param stop_at: Optional object at which to stop the iteration. If ``False``
+        is passed as the value, we will return all the matching objects in the
+        acquisition chain
+    :type stop_at: Content object or False
+    :returns: Iterator of ancestor objects, from immediate to site root.
+    :rtype: iterator
+    :Example: :ref:`content-iter-ancestors-example`
+    """
+    if stop_at is _marker:
+        stop_at = portal.get()
+
+    if obj is stop_at:
+        # We should iterate over the ancestors of obj but obj is also
+        # the object at which we should stop checking for ancestors.
+        # So we should return an empty iterator.
+        #
+        # This is useful if we want to have an empty iterator when checking
+        # for ancestors in the portal.
+        return iter(())
+
+    chain = aq_chain(aq_inner(obj))
+
+    if stop_at:
+        try:
+            end = chain.index(stop_at) + 1
+        except ValueError:
+            raise InvalidParameterError(
+                f"The object {stop_at!r} is not in the acquisition chain of {obj!r}"
+            )
+    else:
+        end = None
+
+    ancestors = islice(chain, 1, end)
+
+    if interface is not None:
+        ancestors = filter(interface.providedBy, ancestors)
+
+    if function is not None:
+        ancestors = filter(function, ancestors)
+
+    yield from ancestors
+
+
+@required_parameters("obj")
+def get_closest_ancestor(obj=None, function=None, interface=None, stop_at=_marker):
+    """Get the closest ancestor that matches the criteria.
+
+    See :func:`~plone.api.content.iter_ancestors` for more information on the parameters.
+
+    :param obj: [required] Object for which we want to get the ancestor.
+    :type obj: Content object
+    :param function: Optional callable that takes an object and returns a boolean.
+    :type function: callable
+    :param interface: Optional interface that should be provided by the ancestor.
+    :type interface: zope.interface.Interface
+    :param stop_at: Optional object at which to stop the iteration. If ``False``
+        is passed as the value, we will return all the matching objects in the
+        acquisition chain
+    :type stop_at: Content object or False
+    :returns: Iterator of ancestor objects, from immediate to site root.
+    :rtype: iterator
+    :Example: :ref:`get-closest-ancestors-example`
+    """
+    return next(
+        iter_ancestors(
+            obj=obj, function=function, interface=interface, stop_at=stop_at
+        ),
+        None,
+    )
diff --git a/src/plone/api/tests/test_content.py b/src/plone/api/tests/test_content.py
index 2c064a6b..c19a7337 100644
--- a/src/plone/api/tests/test_content.py
+++ b/src/plone/api/tests/test_content.py
@@ -6,6 +6,7 @@
 from OFS.interfaces import IObjectWillBeMovedEvent
 from plone import api
 from plone.api.content import _parse_object_provides_query
+from plone.api.exc import MissingParameterError
 from plone.api.tests.base import INTEGRATION_TESTING
 from plone.app.contenttypes.interfaces import IFolder
 from plone.app.linkintegrity.exceptions import LinkIntegrityNotificationException
@@ -14,6 +15,7 @@
 from plone.indexer import indexer
 from plone.uuid.interfaces import IMutableUUID
 from plone.uuid.interfaces import IUUIDGenerator
+from Products.CMFCore.interfaces import IFolderish
 from Products.CMFCore.WorkflowCore import WorkflowException
 from Products.ZCatalog.interfaces import IZCatalog
 from unittest import mock
@@ -1510,3 +1512,147 @@ def getPhysicalPath(self):
         with self.assertRaises(InvalidParameterError) as cm:
             api.content.get_path(outside_obj, relative=True)
         self.assertIn("Object not in portal path", str(cm.exception))
+
+    def test_iter_ancestors_required_parameter(self):
+        """Test that iter_ancestors requires an obj parameter"""
+        with self.assertRaises(MissingParameterError):
+            api.content.iter_ancestors()
+
+    def test_iter_ancestors(self):
+        """Test iterating over all the ancestors until the portal root"""
+        self.assertTupleEqual(
+            tuple(api.content.iter_ancestors(self.team)), (self.about, self.portal)
+        )
+
+    def test_iter_ancestors_deep(self):
+        """Test iterating over all the ancestors of the acquisition chain"""
+        app = self.layer["app"]
+        self.assertTupleEqual(
+            tuple(api.content.iter_ancestors(self.team, stop_at=False)),
+            (self.about, self.portal, app, app.aq_parent),
+        )
+
+    def test_iter_ancestors_stop_at(self):
+        """Test iterating over all the ancestors until a specific object"""
+        app = self.layer["app"]
+        self.assertTupleEqual(
+            tuple(api.content.iter_ancestors(self.team, stop_at=app)),
+            (self.about, self.portal, app),
+        )
+
+    def test_iter_ancestors_with_interface(self):
+        """Test iterating over all the ancestors with an interface filter"""
+        self.assertTupleEqual(
+            tuple(
+                api.content.iter_ancestors(
+                    self.team, interface=IFolderish, stop_at=False
+                )
+            ),
+            (self.about, self.portal),
+        )
+
+    def test_iter_ancestors_with_function(self):
+        """Test iterating over all the ancestors with a function filter"""
+        self.assertTupleEqual(
+            tuple(
+                api.content.iter_ancestors(
+                    self.team, function=lambda x: x.id == "about"
+                )
+            ),
+            (self.about,),
+        )
+
+    def test_iter_ancestors_with_both_filters(self):
+        """Test getting all parents with both filters"""
+        self.assertTupleEqual(
+            tuple(
+                api.content.iter_ancestors(
+                    self.sprint,
+                    interface=IFolderish,
+                    function=lambda x: x.id == "events",
+                )
+            ),
+            (self.events,),
+        )
+
+    def test_iter_ancestors_for_portal(self):
+        """Test iterating over all the ancestors of the portal"""
+        self.assertTupleEqual(tuple(api.content.iter_ancestors(self.portal)), ())
+
+    def test_iter_ancestors_bogus_stop_at(self):
+        """Check that when we pass to the ``stop_at`` parameter something
+        that is not in the acquisition chain, we raise an error."""
+        with self.assertRaises(api.exc.InvalidParameterError) as cm:
+            tuple(api.content.iter_ancestors(self.team, stop_at=self.sprint))
+
+        self.assertEqual(
+            str(cm.exception),
+            (
+                "The object <Event at /plone/events/sprint> "
+                "is not in the acquisition chain of <Document at /plone/about/team>"
+            ),
+        )
+
+    def test_iter_ancestors_not_acquisition_aware_object(self):
+        """Test that iter_ancestors requires an obj parameter"""
+        self.assertTupleEqual(
+            tuple(api.content.iter_ancestors(object(), stop_at=False)), ()
+        )
+
+    def test_get_closest_ancestor_required_parameter(self):
+        """Test that get_closest_ancestor requires an obj parameter"""
+        with self.assertRaises(MissingParameterError):
+            api.content.get_closest_ancestor()
+
+    def test_get_closest_ancestor(self):
+        """Test getting the closest ancestor of an object"""
+        self.assertEqual(api.content.get_closest_ancestor(self.team), self.about)
+
+    def test_get_closest_ancestor_with_interface(self):
+        """Test getting the closest ancestor of an object with an interface filter"""
+        self.assertEqual(
+            api.content.get_closest_ancestor(self.team, interface=IFolderish),
+            self.about,
+        )
+
+    def test_get_closest_ancestor_with_function(self):
+        """Test getting the closest ancestor of an object with a function filter"""
+        self.assertEqual(
+            api.content.get_closest_ancestor(
+                self.team, function=lambda x: x.id == "about"
+            ),
+            self.about,
+        )
+
+    def test_get_closest_ancestor_with_both_filters(self):
+        """Test getting the closest ancestor of an object with both filters"""
+        self.assertEqual(
+            api.content.get_closest_ancestor(
+                self.sprint,
+                interface=IFolderish,
+                function=lambda x: x.id == "events",
+            ),
+            self.events,
+        )
+
+    def test_get_closest_ancestor_for_portal(self):
+        """Test getting the closest ancestor of the portal"""
+        self.assertIsNone(api.content.get_closest_ancestor(self.portal))
+
+    def test_get_closest_ancestor_bogus_stop_at(self):
+        """Check that when we pass to the ``stop_at`` parameter something
+        that is not in the acquisition chain, we raise an error."""
+        with self.assertRaises(api.exc.InvalidParameterError) as cm:
+            api.content.get_closest_ancestor(self.team, stop_at=self.sprint)
+
+        self.assertEqual(
+            str(cm.exception),
+            (
+                "The object <Event at /plone/events/sprint> "
+                "is not in the acquisition chain of <Document at /plone/about/team>"
+            ),
+        )
+
+    def test_get_closest_ancestor_not_acquisition_aware_object(self):
+        """Test that get_closest_ancestor requires an obj parameter"""
+        self.assertIsNone(api.content.get_closest_ancestor(object(), stop_at=False))


Reviews:
user: login: rohnsha0
body: Looks much polished... thanks @ale-rt :)
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-25T17:42:01Z
user: login: davisagli
body: @ale-rt I like the idea of calling it `iter_ancestors` instead of `get_parents` and making it a generator -- but I am sad to lose `get_closest_parent` since it was quite clear and I don't think `next(iter_ancestors( ... ))` is an obvious thing to do for most beginner-to-intermediate Pythonistas. At the very least we need to show it explicitly as an example in the docs.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-26T03:41:23Z
user: login: stevepiercy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-26T07:31:07Z
user: login: stevepiercy
body: I wish we could Black-ify code examples and tests in documentation. Until my wish is granted by a magical unicorn, manually updating them is the only way I know forward.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-26T09:51:12Z
user: login: stevepiercy
body: Docs and change log look good to me. One more pass from @davisagli and @rohnsha0 for a technical review, and I think we're good.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-02-26T10:22:34Z
user: login: rohnsha0
body: LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-02-26T12:51:38Z
user: login: davisagli
body: > I have no strong opinion on having a get_closest_parent (or get_closest_ancestor) function. I am also fine adding that to the documentation. Just let me know.

I have a strong preference for adding an explicit function for this. Yeah it's a one-liner but you have to understand a lot of concepts to write that one-liner:
- iterators
- how the Python next() function works with interators
- the fact that an iterator can raise StopIteration and you have to handle this by passing None as a default value. (I even forgot you can do that until I saw it in your one-liner.)

This is exactly the kind of stuff we want to put inside a function in plone.api so that people who are just trying to get something done don't have to figure out all the mechanics.

I don't have a strong opinion on whether to call it get_closest_parent or get_closest_ancestor or get_next_ancestor.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-04T04:54:03Z
user: login: stevepiercy
body: Just a minor grammar fix, and docs LGTM. Thank you!
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T07:50:56Z
user: login: stevepiercy
body: I approve docs. Code needs technical review.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T09:02:24Z
user: login: gforcada
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-04T18:25:07Z
user: login: davisagli
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T06:03:06Z
user: login: davisagli
body: Thanks!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-05T06:03:42Z
user: login: stevepiercy
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T08:04:51Z
user: login: davisagli
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T15:56:32Z

Review comments:
diff_hunk: @@ -535,151 +535,121 @@ view = api.content.get_view(
 %
 % self.assertEqual(view.__name__, u'plone')
 
+(content-get-path-example)=
+
+## Get content path
 
-(content-get-parents-example)=
+To get the path of a content object, use {func}`api.content.get_path`.
+This function accepts an object for which you want to get its path as the required parameter `obj`, and an optional boolean parameter `relative` whose default is `False`.
 
-## Get parent objects
+It returns either an absolute path from the Zope root by default or when `relative` is set to `False`, or a relative path from the portal root when `relative` is set to `True`.
 
-You can get all parents of a content object (from immediate parent to the portal root) using the {func}`api.content.get_parents` function.
+The following example shows how to get the absolute path from the Zope root.
 
 ```python
 from plone import api
 portal = api.portal.get()
-team = portal['about']['team']
 
-# Get all parents
-parents = api.content.get_parents(obj=team)
+folder = portal['events']['training']
+path = api.content.get_path(obj=folder)
+assert path == '/plone/events/training'
 ```
 
-% invisible-code-block: python
-%
-% self.assertEqual(parents[0], portal['about'])
-
-You can filter parent objects by interface:
+The following example shows how to get the portal-relative path.
 
 ```python
-from plone import api
-from Products.CMFCore.interfaces import IFolderish
-portal = api.portal.get()
-team = portal['about']['team']
-
-# Get folder parents only
-folder_parents = api.content.get_parents(obj=team, interface=IFolderish)
+rel_path = api.content.get_path(obj=folder, relative=True)
+assert rel_path == 'events/training'
 ```
 
+If the API is used to fetch an object with the `relative` parameter set as `True`, and the object is outside the portal, it throws an `InvalidParameterError` error.
+
 % invisible-code-block: python
 %
-% self.assertEqual(folder_parents[0], portal['about'])
-
-You can also filter parents using a custom predicate function:
+% # Setup an object outside portal for testing error case
+% app = portal.aq_parent
+% app.manage_addFolder('outside_folder')
+%
+% # Test that getting relative path for object outside portal raises error
+% from plone.api.exc import InvalidParameterError
+% with self.assertRaises(InvalidParameterError):
+%     api.content.get_path(obj=app.outside_folder, relative=True)
 
 ```python
-from plone import api
-portal = api.portal.get()
-team = portal['about']['team']
-
-# Get only published parents
-def is_published(obj):
-    try:
-        return api.content.get_state(obj=obj) == 'published'
-    except:
-        return False
+from plone.api.exc import InvalidParameterError
 
-published_parents = api.content.get_parents(obj=team, predicate=is_published)
+# Getting path of an object outside portal raises InvalidParameterError
+try:
+    outside_path = api.content.get_path(
+        obj=app.outside_folder,
+        relative=True
+    )
+    assert False, "Should raise InvalidParameterError and not reach this code"
+except InvalidParameterError as e:
+    assert "Object not in portal path" in str(e)
 ```
 
-(content-get-closed-parent-example)=
+(content-iter-ancestors-example)=
 
-## Get closest parent
+## Iterate over object ancestors
 
-To get the closest parent object that matches certain criteria, use the {func}`api.content.get_closest_parent` function.
+To iterate over the ancestors in an object acquisition chain, use the {func}`api.content.iter_ancestors` function.
path: docs/content.md
user: login: davisagli
body: ```suggestion
To iterate over the ancestors in the object tree, use the {func}`api.content.iter_ancestors` function.
```
created_at: 2025-02-26T03:37:58Z
author_association: MEMBER
diff_hunk: @@ -535,151 +535,121 @@ view = api.content.get_view(
 %
 % self.assertEqual(view.__name__, u'plone')
 
+(content-get-path-example)=
+
+## Get content path
 
-(content-get-parents-example)=
+To get the path of a content object, use {func}`api.content.get_path`.
+This function accepts an object for which you want to get its path as the required parameter `obj`, and an optional boolean parameter `relative` whose default is `False`.
 
-## Get parent objects
+It returns either an absolute path from the Zope root by default or when `relative` is set to `False`, or a relative path from the portal root when `relative` is set to `True`.
 
-You can get all parents of a content object (from immediate parent to the portal root) using the {func}`api.content.get_parents` function.
+The following example shows how to get the absolute path from the Zope root.
 
 ```python
 from plone import api
 portal = api.portal.get()
-team = portal['about']['team']
 
-# Get all parents
-parents = api.content.get_parents(obj=team)
+folder = portal['events']['training']
+path = api.content.get_path(obj=folder)
+assert path == '/plone/events/training'
 ```
 
-% invisible-code-block: python
-%
-% self.assertEqual(parents[0], portal['about'])
-
-You can filter parent objects by interface:
+The following example shows how to get the portal-relative path.
 
 ```python
-from plone import api
-from Products.CMFCore.interfaces import IFolderish
-portal = api.portal.get()
-team = portal['about']['team']
-
-# Get folder parents only
-folder_parents = api.content.get_parents(obj=team, interface=IFolderish)
+rel_path = api.content.get_path(obj=folder, relative=True)
+assert rel_path == 'events/training'
 ```
 
+If the API is used to fetch an object with the `relative` parameter set as `True`, and the object is outside the portal, it throws an `InvalidParameterError` error.
+
 % invisible-code-block: python
 %
-% self.assertEqual(folder_parents[0], portal['about'])
-
-You can also filter parents using a custom predicate function:
+% # Setup an object outside portal for testing error case
+% app = portal.aq_parent
+% app.manage_addFolder('outside_folder')
+%
+% # Test that getting relative path for object outside portal raises error
+% from plone.api.exc import InvalidParameterError
+% with self.assertRaises(InvalidParameterError):
+%     api.content.get_path(obj=app.outside_folder, relative=True)
 
 ```python
-from plone import api
-portal = api.portal.get()
-team = portal['about']['team']
-
-# Get only published parents
-def is_published(obj):
-    try:
-        return api.content.get_state(obj=obj) == 'published'
-    except:
-        return False
+from plone.api.exc import InvalidParameterError
 
-published_parents = api.content.get_parents(obj=team, predicate=is_published)
+# Getting path of an object outside portal raises InvalidParameterError
+try:
+    outside_path = api.content.get_path(
+        obj=app.outside_folder,
+        relative=True
+    )
+    assert False, "Should raise InvalidParameterError and not reach this code"
+except InvalidParameterError as e:
+    assert "Object not in portal path" in str(e)
 ```
 
-(content-get-closed-parent-example)=
+(content-iter-ancestors-example)=
 
-## Get closest parent
+## Iterate over object ancestors
 
-To get the closest parent object that matches certain criteria, use the {func}`api.content.get_closest_parent` function.
+To iterate over the ancestors in an object acquisition chain, use the {func}`api.content.iter_ancestors` function.
path: docs/content.md
user: login: stevepiercy
body: Why not have a convenience method of `get_closest_parent` that gets the first iteration of `api.content.iter_ancestors` with either `[0]` or `next`? I'm not sure if it's performant with a large or deep hierarchy.
created_at: 2025-02-26T07:31:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -535,151 +535,121 @@ view = api.content.get_view(
 %
 % self.assertEqual(view.__name__, u'plone')
 
+(content-get-path-example)=
+
+## Get content path
 
-(content-get-parents-example)=
+To get the path of a content object, use {func}`api.content.get_path`.
+This function accepts an object for which you want to get its path as the required parameter `obj`, and an optional boolean parameter `relative` whose default is `False`.
 
-## Get parent objects
+It returns either an absolute path from the Zope root by default or when `relative` is set to `False`, or a relative path from the portal root when `relative` is set to `True`.
 
-You can get all parents of a content object (from immediate parent to the portal root) using the {func}`api.content.get_parents` function.
+The following example shows how to get the absolute path from the Zope root.
 
 ```python
 from plone import api
 portal = api.portal.get()
-team = portal['about']['team']
 
-# Get all parents
-parents = api.content.get_parents(obj=team)
+folder = portal['events']['training']
+path = api.content.get_path(obj=folder)
+assert path == '/plone/events/training'
 ```
 
-% invisible-code-block: python
-%
-% self.assertEqual(parents[0], portal['about'])
-
-You can filter parent objects by interface:
+The following example shows how to get the portal-relative path.
 
 ```python
-from plone import api
-from Products.CMFCore.interfaces import IFolderish
-portal = api.portal.get()
-team = portal['about']['team']
-
-# Get folder parents only
-folder_parents = api.content.get_parents(obj=team, interface=IFolderish)
+rel_path = api.content.get_path(obj=folder, relative=True)
+assert rel_path == 'events/training'
 ```
 
+If the API is used to fetch an object with the `relative` parameter set as `True`, and the object is outside the portal, it throws an `InvalidParameterError` error.
+
 % invisible-code-block: python
 %
-% self.assertEqual(folder_parents[0], portal['about'])
-
-You can also filter parents using a custom predicate function:
+% # Setup an object outside portal for testing error case
+% app = portal.aq_parent
+% app.manage_addFolder('outside_folder')
+%
+% # Test that getting relative path for object outside portal raises error
+% from plone.api.exc import InvalidParameterError
+% with self.assertRaises(InvalidParameterError):
+%     api.content.get_path(obj=app.outside_folder, relative=True)
 
 ```python
-from plone import api
-portal = api.portal.get()
-team = portal['about']['team']
-
-# Get only published parents
-def is_published(obj):
-    try:
-        return api.content.get_state(obj=obj) == 'published'
-    except:
-        return False
+from plone.api.exc import InvalidParameterError
 
-published_parents = api.content.get_parents(obj=team, predicate=is_published)
+# Getting path of an object outside portal raises InvalidParameterError
+try:
+    outside_path = api.content.get_path(
+        obj=app.outside_folder,
+        relative=True
+    )
+    assert False, "Should raise InvalidParameterError and not reach this code"
+except InvalidParameterError as e:
+    assert "Object not in portal path" in str(e)
 ```
 
-(content-get-closed-parent-example)=
+(content-iter-ancestors-example)=
 
-## Get closest parent
+## Iterate over object ancestors
 
-To get the closest parent object that matches certain criteria, use the {func}`api.content.get_closest_parent` function.
+To iterate over the ancestors in an object acquisition chain, use the {func}`api.content.iter_ancestors` function.
 
 ```python
 from plone import api
-from Products.CMFCore.interfaces import IFolderish
 portal = api.portal.get()
-team = portal['about']['team']
 
-# Get immediate parent folder
-parent_folder = api.content.get_closest_parent(obj=team, interface=IFolderish)
+# Get all ancestors of the team object
+ancestors = api.content.iter_ancestors(portal.about.team)
 ```
 
 % invisible-code-block: python
 %
-% self.assertEqual(parent_folder.id, 'about')
+% self.assertTupleEqual(tuple(ancestors), (portal.about, portal))
 
-You can also use a predicate function to find the closest parent matching custom criteria:
+To iterate over the ancestors that implement one interface, use the {func}`api.content.iter_ancestors` function with the `interface` argument.
 
 ```python
 from plone import api
+from Products.CMFCore.interfaces import ISiteRoot
 portal = api.portal.get()
-team = portal['about']['team']
-
-# Get closest published parent
-def is_published(obj):
-    try:
-        return api.content.get_state(obj=obj) == 'published'
-    except:
-        return False
 
-closest_published = api.content.get_closest_parent(obj=team, predicate=is_published)
+# Get all ancestors of the team object that implement the ISiteRoot interface
+ancestors = api.content.iter_ancestors(portal.about.team, interface=ISiteRoot)
 ```
 
+% invisible-code-block: python
+%
+% self.assertTupleEqual(tuple(ancestors), (portal,))
 
-(content-get-path-example)=
-
-## Get content path
-
-To get the path of a content object, use {func}`api.content.get_path`.
-This function accepts an object for which you want to get its path as the required parameter `obj`, and an optional boolean parameter `relative` whose default is `False`.
-
-It returns either an absolute path from the Zope root by default or when `relative` is set to `False`, or a relative path from the portal root when `relative` is set to `True`.
-
-The following example shows how to get the absolute path from the Zope root.
+To iterate over the ancestors using a custom filter you can use the {func}`api.content.iter_ancestors` function with the `function` argument.
 
 ```python
 from plone import api
 portal = api.portal.get()
 
-folder = portal['events']['training']
-path = api.content.get_path(obj=folder)
-assert path == '/plone/events/training'
+# Get all ancestors of the team object with the id 'about'
+ancestors = api.content.iter_ancestors(portal.about.team, function=lambda obj: obj.id == 'about')
path: docs/content.md
user: login: stevepiercy
body: ```suggestion
ancestors = api.content.iter_ancestors(portal.about.team, function=lambda obj: obj.id == "about")
```
created_at: 2025-02-26T09:46:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -535,151 +535,121 @@ view = api.content.get_view(
 %
 % self.assertEqual(view.__name__, u'plone')
 
+(content-get-path-example)=
+
+## Get content path
 
-(content-get-parents-example)=
+To get the path of a content object, use {func}`api.content.get_path`.
+This function accepts an object for which you want to get its path as the required parameter `obj`, and an optional boolean parameter `relative` whose default is `False`.
 
-## Get parent objects
+It returns either an absolute path from the Zope root by default or when `relative` is set to `False`, or a relative path from the portal root when `relative` is set to `True`.
 
-You can get all parents of a content object (from immediate parent to the portal root) using the {func}`api.content.get_parents` function.
+The following example shows how to get the absolute path from the Zope root.
 
 ```python
 from plone import api
 portal = api.portal.get()
-team = portal['about']['team']
 
-# Get all parents
-parents = api.content.get_parents(obj=team)
+folder = portal['events']['training']
+path = api.content.get_path(obj=folder)
+assert path == '/plone/events/training'
 ```
 
-% invisible-code-block: python
-%
-% self.assertEqual(parents[0], portal['about'])
-
-You can filter parent objects by interface:
+The following example shows how to get the portal-relative path.
 
 ```python
-from plone import api
-from Products.CMFCore.interfaces import IFolderish
-portal = api.portal.get()
-team = portal['about']['team']
-
-# Get folder parents only
-folder_parents = api.content.get_parents(obj=team, interface=IFolderish)
+rel_path = api.content.get_path(obj=folder, relative=True)
+assert rel_path == 'events/training'
 ```
 
+If the API is used to fetch an object with the `relative` parameter set as `True`, and the object is outside the portal, it throws an `InvalidParameterError` error.
+
 % invisible-code-block: python
 %
-% self.assertEqual(folder_parents[0], portal['about'])
-
-You can also filter parents using a custom predicate function:
+% # Setup an object outside portal for testing error case
+% app = portal.aq_parent
+% app.manage_addFolder('outside_folder')
path: docs/content.md
user: login: stevepiercy
body: ```suggestion
% app.manage_addFolder("outside_folder")
```
created_at: 2025-02-26T09:46:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -535,151 +535,121 @@ view = api.content.get_view(
 %
 % self.assertEqual(view.__name__, u'plone')
 
+(content-get-path-example)=
+
+## Get content path
 
-(content-get-parents-example)=
+To get the path of a content object, use {func}`api.content.get_path`.
+This function accepts an object for which you want to get its path as the required parameter `obj`, and an optional boolean parameter `relative` whose default is `False`.
 
-## Get parent objects
+It returns either an absolute path from the Zope root by default or when `relative` is set to `False`, or a relative path from the portal root when `relative` is set to `True`.
 
-You can get all parents of a content object (from immediate parent to the portal root) using the {func}`api.content.get_parents` function.
+The following example shows how to get the absolute path from the Zope root.
 
 ```python
 from plone import api
 portal = api.portal.get()
-team = portal['about']['team']
 
-# Get all parents
-parents = api.content.get_parents(obj=team)
+folder = portal['events']['training']
+path = api.content.get_path(obj=folder)
+assert path == '/plone/events/training'
path: docs/content.md
user: login: stevepiercy
body: ```suggestion
assert path == "/plone/events/training"
```
created_at: 2025-02-26T09:46:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -535,151 +535,121 @@ view = api.content.get_view(
 %
 % self.assertEqual(view.__name__, u'plone')
 
+(content-get-path-example)=
+
+## Get content path
 
-(content-get-parents-example)=
+To get the path of a content object, use {func}`api.content.get_path`.
+This function accepts an object for which you want to get its path as the required parameter `obj`, and an optional boolean parameter `relative` whose default is `False`.
 
-## Get parent objects
+It returns either an absolute path from the Zope root by default or when `relative` is set to `False`, or a relative path from the portal root when `relative` is set to `True`.
 
-You can get all parents of a content object (from immediate parent to the portal root) using the {func}`api.content.get_parents` function.
+The following example shows how to get the absolute path from the Zope root.
 
 ```python
 from plone import api
 portal = api.portal.get()
-team = portal['about']['team']
 
-# Get all parents
-parents = api.content.get_parents(obj=team)
+folder = portal['events']['training']
path: docs/content.md
user: login: stevepiercy
body: ```suggestion
folder = portal["events"]["training"]
```
created_at: 2025-02-26T09:46:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -535,151 +535,121 @@ view = api.content.get_view(
 %
 % self.assertEqual(view.__name__, u'plone')
 
+(content-get-path-example)=
+
+## Get content path
 
-(content-get-parents-example)=
+To get the path of a content object, use {func}`api.content.get_path`.
+This function accepts an object for which you want to get its path as the required parameter `obj`, and an optional boolean parameter `relative` whose default is `False`.
 
-## Get parent objects
+It returns either an absolute path from the Zope root by default or when `relative` is set to `False`, or a relative path from the portal root when `relative` is set to `True`.
 
-You can get all parents of a content object (from immediate parent to the portal root) using the {func}`api.content.get_parents` function.
+The following example shows how to get the absolute path from the Zope root.
 
 ```python
 from plone import api
 portal = api.portal.get()
-team = portal['about']['team']
 
-# Get all parents
-parents = api.content.get_parents(obj=team)
+folder = portal['events']['training']
+path = api.content.get_path(obj=folder)
+assert path == '/plone/events/training'
 ```
 
-% invisible-code-block: python
-%
-% self.assertEqual(parents[0], portal['about'])
-
-You can filter parent objects by interface:
+The following example shows how to get the portal-relative path.
 
 ```python
-from plone import api
-from Products.CMFCore.interfaces import IFolderish
-portal = api.portal.get()
-team = portal['about']['team']
-
-# Get folder parents only
-folder_parents = api.content.get_parents(obj=team, interface=IFolderish)
+rel_path = api.content.get_path(obj=folder, relative=True)
+assert rel_path == 'events/training'
path: docs/content.md
user: login: stevepiercy
body: ```suggestion
assert rel_path == "events/training"
```
created_at: 2025-02-26T09:47:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -535,151 +535,121 @@ view = api.content.get_view(
 %
 % self.assertEqual(view.__name__, u'plone')
 
+(content-get-path-example)=
+
+## Get content path
 
-(content-get-parents-example)=
+To get the path of a content object, use {func}`api.content.get_path`.
+This function accepts an object for which you want to get its path as the required parameter `obj`, and an optional boolean parameter `relative` whose default is `False`.
 
-## Get parent objects
+It returns either an absolute path from the Zope root by default or when `relative` is set to `False`, or a relative path from the portal root when `relative` is set to `True`.
 
-You can get all parents of a content object (from immediate parent to the portal root) using the {func}`api.content.get_parents` function.
+The following example shows how to get the absolute path from the Zope root.
 
 ```python
 from plone import api
 portal = api.portal.get()
-team = portal['about']['team']
 
-# Get all parents
-parents = api.content.get_parents(obj=team)
+folder = portal['events']['training']
+path = api.content.get_path(obj=folder)
+assert path == '/plone/events/training'
 ```
 
-% invisible-code-block: python
-%
-% self.assertEqual(parents[0], portal['about'])
-
-You can filter parent objects by interface:
+The following example shows how to get the portal-relative path.
 
 ```python
-from plone import api
-from Products.CMFCore.interfaces import IFolderish
-portal = api.portal.get()
-team = portal['about']['team']
-
-# Get folder parents only
-folder_parents = api.content.get_parents(obj=team, interface=IFolderish)
+rel_path = api.content.get_path(obj=folder, relative=True)
+assert rel_path == 'events/training'
 ```
 
+If the API is used to fetch an object with the `relative` parameter set as `True`, and the object is outside the portal, it throws an `InvalidParameterError` error.
+
 % invisible-code-block: python
 %
-% self.assertEqual(folder_parents[0], portal['about'])
-
-You can also filter parents using a custom predicate function:
+% # Setup an object outside portal for testing error case
+% app = portal.aq_parent
+% app.manage_addFolder('outside_folder')
+%
+% # Test that getting relative path for object outside portal raises error
+% from plone.api.exc import InvalidParameterError
+% with self.assertRaises(InvalidParameterError):
+%     api.content.get_path(obj=app.outside_folder, relative=True)
 
 ```python
-from plone import api
-portal = api.portal.get()
-team = portal['about']['team']
-
-# Get only published parents
-def is_published(obj):
-    try:
-        return api.content.get_state(obj=obj) == 'published'
-    except:
-        return False
+from plone.api.exc import InvalidParameterError
 
-published_parents = api.content.get_parents(obj=team, predicate=is_published)
+# Getting path of an object outside portal raises InvalidParameterError
+try:
+    outside_path = api.content.get_path(
+        obj=app.outside_folder,
+        relative=True
+    )
+    assert False, "Should raise InvalidParameterError and not reach this code"
+except InvalidParameterError as e:
+    assert "Object not in portal path" in str(e)
 ```
 
-(content-get-closed-parent-example)=
+(content-iter-ancestors-example)=
 
-## Get closest parent
+## Iterate over object ancestors
 
-To get the closest parent object that matches certain criteria, use the {func}`api.content.get_closest_parent` function.
+To iterate over the ancestors in an object acquisition chain, use the {func}`api.content.iter_ancestors` function.
 
 ```python
 from plone import api
-from Products.CMFCore.interfaces import IFolderish
 portal = api.portal.get()
-team = portal['about']['team']
 
-# Get immediate parent folder
-parent_folder = api.content.get_closest_parent(obj=team, interface=IFolderish)
+# Get all ancestors of the team object
+ancestors = api.content.iter_ancestors(portal.about.team)
 ```
 
 % invisible-code-block: python
 %
-% self.assertEqual(parent_folder.id, 'about')
+% self.assertTupleEqual(tuple(ancestors), (portal.about, portal))
 
-You can also use a predicate function to find the closest parent matching custom criteria:
+To iterate over the ancestors that implement one interface, use the {func}`api.content.iter_ancestors` function with the `interface` argument.
 
 ```python
 from plone import api
+from Products.CMFCore.interfaces import ISiteRoot
 portal = api.portal.get()
-team = portal['about']['team']
-
-# Get closest published parent
-def is_published(obj):
-    try:
-        return api.content.get_state(obj=obj) == 'published'
-    except:
-        return False
 
-closest_published = api.content.get_closest_parent(obj=team, predicate=is_published)
+# Get all ancestors of the team object that implement the ISiteRoot interface
+ancestors = api.content.iter_ancestors(portal.about.team, interface=ISiteRoot)
 ```
 
+% invisible-code-block: python
+%
+% self.assertTupleEqual(tuple(ancestors), (portal,))
 
-(content-get-path-example)=
-
-## Get content path
-
-To get the path of a content object, use {func}`api.content.get_path`.
-This function accepts an object for which you want to get its path as the required parameter `obj`, and an optional boolean parameter `relative` whose default is `False`.
-
-It returns either an absolute path from the Zope root by default or when `relative` is set to `False`, or a relative path from the portal root when `relative` is set to `True`.
-
-The following example shows how to get the absolute path from the Zope root.
+To iterate over the ancestors using a custom filter you can use the {func}`api.content.iter_ancestors` function with the `function` argument.
 
 ```python
 from plone import api
 portal = api.portal.get()
 
-folder = portal['events']['training']
-path = api.content.get_path(obj=folder)
-assert path == '/plone/events/training'
+# Get all ancestors of the team object with the id 'about'
+ancestors = api.content.iter_ancestors(portal.about.team, function=lambda obj: obj.id == 'about')
 ```
 
-The following example shows how to get the portal-relative path.
-
-```python
-rel_path = api.content.get_path(obj=folder, relative=True)
-assert rel_path == 'events/training'
-```
-
-If the API is used to fetch an object with the `relative` parameter set as `True`, and the object is outside the portal, it throws an `InvalidParameterError` error.
-
 % invisible-code-block: python
 %
-% # Setup an object outside portal for testing error case
-% app = portal.aq_parent
-% app.manage_addFolder('outside_folder')
-%
-% # Test that getting relative path for object outside portal raises error
-% from plone.api.exc import InvalidParameterError
-% with self.assertRaises(InvalidParameterError):
-%     api.content.get_path(obj=app.outside_folder, relative=True)
+% self.assertTupleEqual(tuple(ancestors), (portal.about,))
+
+To iterate over the ancestors until an object is found you use the {func}`api.content.iter_ancestors` function with the `stop_at` argument.
path: docs/content.md
user: login: stevepiercy
body: ```suggestion
To iterate over the ancestors until an object is found, use the {func}`api.content.iter_ancestors` function with the `stop_at` argument.
```
created_at: 2025-02-26T09:48:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1 +1 @@
-Add `get_parents` and `get_closest_parent` functions for content hierarchy retrieval @rohnsha0
\ No newline at end of file
+Add the ``api.content.iter_ancestors`` function for iterating over an object acquisition chain @rohnsha0, @ale-rt
path: news/531.feature
user: login: stevepiercy
body: ```suggestion
Add the ``api.content.iter_ancestors`` function for iterating over an object acquisition chain. @rohnsha0, @ale-rt
```
created_at: 2025-02-26T09:48:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -650,6 +650,38 @@ ancestors = api.content.iter_ancestors(portal.about.team, stop_at=portal.about)
 %
 % self.assertTupleEqual(tuple(ancestors), (portal.about,))
 
+(content-get-closest-ancestor-example)=
+
+## Get closest ancestor
+
+To get the closest ancestor in the object tree, use the {func}`api.content.get_closest_ancestor` function.
+
+```python
+from plone import api
+from Products.CMFCore.interfaces import ISiteRoot
+portal = api.portal.get()
+
+# Get the closest ancestor of the team object that implements the ISiteRoot interface
+ancestor = api.content.get_closest_ancestor(portal.about.team, interface=ISiteRoot)
+```
+
+% invisible-code-block: python
+%
+% self.assertEqual(ancestor, portal)
+
+To get the closest ancestor that using a custom filter, use the {func}`api.content.get_closest_ancestor` function with the `function` argument.
path: docs/content.md
user: login: stevepiercy
body: ```suggestion
To get the closest ancestor by using a custom filter, use the {func}`api.content.get_closest_ancestor` function with the `function` argument.
```
created_at: 2025-03-04T07:49:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1 @@
+Add the ``api.content.iter_ancestors`` function for iterating over an object acquisition chain. @rohnsha0, @ale-rt
path: news/531.feature
user: login: davisagli
body: ```suggestion
Add the `api.content.iter_ancestors` function for iterating over an object acquisition chain. @rohnsha0, @ale-rt
```
created_at: 2025-03-05T06:03:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1 @@
+Add the ``api.content.iter_ancestors`` function for iterating over an object acquisition chain. @rohnsha0, @ale-rt
path: news/531.feature
user: login: stevepiercy
body: @davisagli the destination file is `CHANGES.rst`, so inline literals require double backticks. If we ever change it to `CHANGES.md`, then we can use Markdown syntax.
created_at: 2025-03-05T08:04:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1 @@
+Add the ``api.content.iter_ancestors`` function for iterating over an object acquisition chain. @rohnsha0, @ale-rt
path: news/531.feature
user: login: davisagli
body: @stevepiercy Oh! Let's go ahead and convert it to Markdown so it matches everything else.
created_at: 2025-03-05T15:56:32Z
author_association: MEMBER

PR comments:
user: login: ale-rt
created_at: 2025-02-26T08:19:26Z
body: I have no strong opinion on having a `get_closest_parent` (or `get_closest_ancestor`) function.
That function will anyway just be a one liner:

```python
return next(api.content.iter_ancestors(...), None)
```

I am also fine adding that to the documentation.

Just let me know.




author_association: MEMBER
user: login: stevepiercy
created_at: 2025-02-26T09:43:32Z
body: RTD PR preview builds should work again.
author_association: CONTRIBUTOR
user: login: stevepiercy
created_at: 2025-02-26T10:16:19Z
body: RTD PR preview build fails because of a missing requirements file. https://app.readthedocs.org/projects/ploneapi/builds/27320031/

```console
 ERROR: Could not open requirements file: [Errno 2] No such file or directory: 'constraints_plone61.txt' 
```
author_association: CONTRIBUTOR
user: login: stevepiercy
created_at: 2025-02-26T10:17:05Z
body: It's good now: https://app.readthedocs.org/projects/ploneapi/builds/27320042/
author_association: CONTRIBUTOR
user: login: ale-rt
created_at: 2025-03-04T07:08:04Z
body: I would go for `get_closest_ancestor`!
author_association: MEMBER
user: login: ale-rt
created_at: 2025-03-04T09:14:37Z
body: @jenkins-plone-org please run jobs
author_association: MEMBER
user: login: ale-rt
created_at: 2025-03-05T06:26:37Z
body: Thanks to you all :)
author_association: MEMBER